initial commit
78
.gitignore
vendored
Normal file
@@ -0,0 +1,78 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
PIPFILE.lock
|
||||
|
||||
# Virtual Environment
|
||||
venv/
|
||||
ENV/
|
||||
env/
|
||||
.venv
|
||||
|
||||
# PyCharm
|
||||
.idea/
|
||||
*.iml
|
||||
*.iws
|
||||
.idea_modules/
|
||||
|
||||
# VS Code
|
||||
.vscode/
|
||||
|
||||
# Database files
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
db/*.json
|
||||
|
||||
# Flask
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Node.js / Vue (web directory)
|
||||
web/node_modules/
|
||||
web/npm-debug.log*
|
||||
web/yarn-debug.log*
|
||||
web/yarn-error.log*
|
||||
web/dist/
|
||||
web/.nuxt/
|
||||
web/.cache/
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# Testing
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
.tox/
|
||||
296
api/child_api.py
Normal file
@@ -0,0 +1,296 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
from tinydb import Query
|
||||
from db.db import child_db, task_db, reward_db
|
||||
from api.reward_status import RewardStatus
|
||||
from api.child_tasks import ChildTask
|
||||
|
||||
from models.child import Child
|
||||
from models.task import Task
|
||||
from models.reward import Reward
|
||||
|
||||
child_api = Blueprint('child_api', __name__)
|
||||
|
||||
@child_api.route('/child/<name>', methods=['GET'])
|
||||
@child_api.route('/child/<id>', methods=['GET'])
|
||||
def get_child(id):
|
||||
ChildQuery = Query()
|
||||
result = child_db.search(ChildQuery.id == id)
|
||||
if not result:
|
||||
return jsonify({'error': 'Child not found'}), 404
|
||||
return jsonify(result[0]), 200
|
||||
|
||||
@child_api.route('/child/add', methods=['PUT'])
|
||||
def add_child():
|
||||
data = request.get_json()
|
||||
name = data.get('name')
|
||||
age = data.get('age')
|
||||
image = data.get('image_id', None)
|
||||
if not name:
|
||||
return jsonify({'error': 'Name is required'}), 400
|
||||
if not image:
|
||||
image = 'boy01'
|
||||
|
||||
child = Child(name, age, image_id=image)
|
||||
child_db.insert(child.to_dict())
|
||||
return jsonify({'message': f'Child {name} added.'}), 201
|
||||
|
||||
@child_api.route('/child/<id>/edit', methods=['PUT'])
|
||||
def edit_child(id):
|
||||
data = request.get_json()
|
||||
name = data.get('name', None)
|
||||
age = data.get('age', None)
|
||||
points = data.get('points', None)
|
||||
image = data.get('image_id', None)
|
||||
ChildQuery = Query()
|
||||
result = child_db.search(ChildQuery.id == id)
|
||||
if not result:
|
||||
return jsonify({'error': 'Child not found'}), 404
|
||||
child = result[0]
|
||||
if name is not None:
|
||||
child['name'] = name
|
||||
if age is not None:
|
||||
child['age'] = age
|
||||
if points is not None:
|
||||
child['points'] = points
|
||||
if image is not None:
|
||||
child['image_id'] = image
|
||||
child_db.update(child, ChildQuery.id == id)
|
||||
return jsonify({'message': f'Child {id} updated.'}), 200
|
||||
|
||||
@child_api.route('/child/list', methods=['GET'])
|
||||
def list_children():
|
||||
children = child_db.all()
|
||||
return jsonify({'children': children}), 200
|
||||
|
||||
# Child DELETE
|
||||
@child_api.route('/child/<id>', methods=['DELETE'])
|
||||
def delete_child(id):
|
||||
ChildQuery = Query()
|
||||
if child_db.remove(ChildQuery.id == id):
|
||||
return jsonify({'message': f'Child {id} deleted.'}), 200
|
||||
return jsonify({'error': 'Child not found'}), 404
|
||||
|
||||
@child_api.route('/child/<id>/assign-task', methods=['POST'])
|
||||
def assign_task_to_child(id):
|
||||
data = request.get_json()
|
||||
task_id = data.get('task_id')
|
||||
if not task_id:
|
||||
return jsonify({'error': 'task_id is required'}), 400
|
||||
|
||||
ChildQuery = Query()
|
||||
result = child_db.search(ChildQuery.id == id)
|
||||
if not result:
|
||||
return jsonify({'error': 'Child not found'}), 404
|
||||
|
||||
child = result[0]
|
||||
if task_id not in child.get('tasks', []):
|
||||
child['tasks'].append(task_id)
|
||||
child_db.update({'tasks': child['tasks']}, ChildQuery.id == id)
|
||||
return jsonify({'message': f'Task {task_id} assigned to {child['name']}.'}), 200
|
||||
|
||||
|
||||
|
||||
@child_api.route('/child/<id>/remove-task', methods=['POST'])
|
||||
def remove_task_from_child(id):
|
||||
data = request.get_json()
|
||||
task_id = data.get('task_id')
|
||||
if not task_id:
|
||||
return jsonify({'error': 'task_id is required'}), 400
|
||||
|
||||
ChildQuery = Query()
|
||||
result = child_db.search(ChildQuery.id == id)
|
||||
if not result:
|
||||
return jsonify({'error': 'Child not found'}), 404
|
||||
|
||||
child = result[0]
|
||||
if task_id in child.get('tasks', []):
|
||||
child['tasks'].remove(task_id)
|
||||
child_db.update({'tasks': child['tasks']}, ChildQuery.id == id)
|
||||
return jsonify({'message': f'Task {task_id} removed from {child["name"]}.'}), 200
|
||||
return jsonify({'error': 'Task not assigned to child'}), 400
|
||||
|
||||
@child_api.route('/child/<id>/list-tasks', methods=['GET'])
|
||||
def list_child_tasks(id):
|
||||
ChildQuery = Query()
|
||||
result = child_db.search(ChildQuery.id == id)
|
||||
if not result:
|
||||
return jsonify({'error': 'Child not found'}), 404
|
||||
|
||||
child = result[0]
|
||||
task_ids = child.get('tasks', [])
|
||||
|
||||
TaskQuery = Query()
|
||||
child_tasks = []
|
||||
for tid in task_ids:
|
||||
task = task_db.get(TaskQuery.id == tid)
|
||||
if not task:
|
||||
continue
|
||||
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())
|
||||
|
||||
return jsonify({'child_tasks': child_tasks}), 200
|
||||
|
||||
@child_api.route('/child/<id>/list-assignable-tasks', methods=['GET'])
|
||||
def list_assignable_tasks(id):
|
||||
ChildQuery = Query()
|
||||
result = child_db.search(ChildQuery.id == id)
|
||||
if not result:
|
||||
return jsonify({'error': 'Child not found'}), 404
|
||||
|
||||
child = result[0]
|
||||
assigned_ids = set(child.get('tasks', []))
|
||||
|
||||
# Collect all task ids from the task database
|
||||
all_task_ids = [t.get('id') for t in task_db.all() if t and t.get('id')]
|
||||
|
||||
# Filter out already assigned
|
||||
assignable_ids = [tid for tid in all_task_ids if tid not in assigned_ids]
|
||||
|
||||
# Fetch full task details and wrap in ChildTask
|
||||
TaskQuery = Query()
|
||||
assignable_tasks = []
|
||||
for tid in assignable_ids:
|
||||
task = task_db.get(TaskQuery.id == tid)
|
||||
if not task:
|
||||
continue
|
||||
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())
|
||||
|
||||
return jsonify({'assignable_tasks': assignable_tasks, 'count': len(assignable_tasks)}), 200
|
||||
|
||||
@child_api.route('/child/<id>/trigger-task', methods=['POST'])
|
||||
def trigger_child_task(id):
|
||||
data = request.get_json()
|
||||
task_id = data.get('task_id')
|
||||
if not task_id:
|
||||
return jsonify({'error': 'task_id is required'}), 400
|
||||
|
||||
ChildQuery = Query()
|
||||
result = child_db.search(ChildQuery.id == id)
|
||||
if not result:
|
||||
return jsonify({'error': 'Child not found'}), 404
|
||||
|
||||
child: Child = Child.from_dict(result[0])
|
||||
if task_id not in child.tasks:
|
||||
return jsonify({'error': f'Task not found assigned to child {child.name}'}), 404
|
||||
# look up the task and get the details
|
||||
TaskQuery = Query()
|
||||
task_result = task_db.search(TaskQuery.id == task_id)
|
||||
if not task_result:
|
||||
return jsonify({'error': 'Task not found in task database'}), 404
|
||||
task: Task = Task.from_dict(task_result[0])
|
||||
# update the child's points based on task type
|
||||
if task.is_good:
|
||||
child.points += task.points
|
||||
else:
|
||||
child.points -= task.points
|
||||
child.points = max(child.points, 0)
|
||||
# update the child in the database
|
||||
child_db.update({'points': child.points}, ChildQuery.id == id)
|
||||
|
||||
return jsonify({'message': f'{task.name} points assigned to {child.name}.', 'points': child.points, 'id': child.id}), 200
|
||||
|
||||
@child_api.route('/child/<id>/assign-reward', methods=['POST'])
|
||||
def assign_reward_to_child(id):
|
||||
data = request.get_json()
|
||||
reward_id = data.get('reward_id')
|
||||
if not reward_id:
|
||||
return jsonify({'error': 'reward_id is required'}), 400
|
||||
|
||||
ChildQuery = Query()
|
||||
result = child_db.search(ChildQuery.id == id)
|
||||
if not result:
|
||||
return jsonify({'error': 'Child not found'}), 404
|
||||
|
||||
child = result[0]
|
||||
if reward_id not in child.get('rewards', []):
|
||||
child['rewards'].append(reward_id)
|
||||
child_db.update({'rewards': child['rewards']}, ChildQuery.id == id)
|
||||
return jsonify({'message': f'Reward {reward_id} assigned to {child["name"]}.'}), 200
|
||||
|
||||
@child_api.route('/child/<id>/remove-reward', methods=['POST'])
|
||||
def remove_reward_from_child(id):
|
||||
data = request.get_json()
|
||||
reward_id = data.get('reward_id')
|
||||
if not reward_id:
|
||||
return jsonify({'error': 'reward_id is required'}), 400
|
||||
|
||||
ChildQuery = Query()
|
||||
result = child_db.search(ChildQuery.id == id)
|
||||
if not result:
|
||||
return jsonify({'error': 'Child not found'}), 404
|
||||
|
||||
child = result[0]
|
||||
if reward_id in child.get('rewards', []):
|
||||
child['rewards'].remove(reward_id)
|
||||
child_db.update({'rewards': child['rewards']}, ChildQuery.id == id)
|
||||
return jsonify({'message': f'Reward {reward_id} removed from {child["name"]}.'}), 200
|
||||
return jsonify({'error': 'Reward not assigned to child'}), 400
|
||||
|
||||
@child_api.route('/child/<id>/trigger-reward', methods=['POST'])
|
||||
def trigger_child_reward(id):
|
||||
data = request.get_json()
|
||||
reward_id = data.get('reward_id')
|
||||
if not reward_id:
|
||||
return jsonify({'error': 'reward_id is required'}), 400
|
||||
|
||||
ChildQuery = Query()
|
||||
result = child_db.search(ChildQuery.id == id)
|
||||
if not result:
|
||||
return jsonify({'error': 'Child not found'}), 404
|
||||
|
||||
child: Child = Child.from_dict(result[0])
|
||||
if reward_id not in child.rewards:
|
||||
return jsonify({'error': f'Reward not found assigned to child {child.name}'}), 404
|
||||
# look up the task and get the details
|
||||
RewardQuery = Query()
|
||||
reward_result = reward_db.search(RewardQuery.id == reward_id)
|
||||
if not reward_result:
|
||||
return jsonify({'error': 'Reward not found in reward database'}), 404
|
||||
reward: Reward = Reward.from_dict(reward_result[0])
|
||||
# update the child's points based on reward cost
|
||||
child.points -= reward.cost
|
||||
# update the child in the database
|
||||
child_db.update({'points': child.points}, ChildQuery.id == id)
|
||||
return jsonify({'message': f'{reward.name} assigned to {child.name}.', 'points': child.points, 'id': child.id}), 200
|
||||
|
||||
@child_api.route('/child/<id>/affordable-rewards', methods=['GET'])
|
||||
def list_affordable_rewards(id):
|
||||
ChildQuery = Query()
|
||||
result = child_db.search(ChildQuery.id == id)
|
||||
if not result:
|
||||
return jsonify({'error': 'Child not found'}), 404
|
||||
|
||||
child = result[0]
|
||||
points = child.get('points', 0)
|
||||
reward_ids = child.get('rewards', [])
|
||||
RewardQuery = Query()
|
||||
affordable = [
|
||||
reward for reward_id in reward_ids
|
||||
if (reward := reward_db.get(RewardQuery.id == reward_id)) and points >= reward.get('cost', 0)
|
||||
]
|
||||
return jsonify({'affordable_rewards': affordable}), 200
|
||||
|
||||
@child_api.route('/child/<id>/reward-status', methods=['GET'])
|
||||
def reward_status(id):
|
||||
ChildQuery = Query()
|
||||
result = child_db.search(ChildQuery.id == id)
|
||||
if not result:
|
||||
return jsonify({'error': 'Child not found'}), 404
|
||||
|
||||
child = result[0]
|
||||
points = child.get('points', 0)
|
||||
reward_ids = child.get('rewards', [])
|
||||
|
||||
RewardQuery = Query()
|
||||
statuses = []
|
||||
for reward_id in reward_ids:
|
||||
reward = reward_db.get(RewardQuery.id == reward_id)
|
||||
if not reward:
|
||||
continue
|
||||
points_needed = max(0, reward.get('cost', 0) - points)
|
||||
status = RewardStatus(reward.get('id'), reward.get('name'), points_needed, reward.get('image_id'))
|
||||
statuses.append(status.to_dict())
|
||||
|
||||
statuses.sort(key=lambda s: s['points_needed'])
|
||||
return jsonify({'reward_status': statuses}), 200
|
||||
16
api/child_tasks.py
Normal file
@@ -0,0 +1,16 @@
|
||||
class ChildTask:
|
||||
def __init__(self, name, is_good, points, image_id, id):
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.is_good = is_good
|
||||
self.points = points
|
||||
self.image_id = image_id
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
'is_good': self.is_good,
|
||||
'points': self.points,
|
||||
'image_id': self.image_id
|
||||
}
|
||||
107
api/image_api.py
Normal file
@@ -0,0 +1,107 @@
|
||||
import os
|
||||
import uuid
|
||||
from io import BytesIO
|
||||
|
||||
from flask import Blueprint, request, jsonify, send_file
|
||||
from db.db import image_db
|
||||
from models.image import Image
|
||||
from tinydb import Query
|
||||
from PIL import Image as PILImage, UnidentifiedImageError
|
||||
|
||||
image_api = Blueprint('image_api', __name__)
|
||||
UPLOAD_FOLDER = './resources/images'
|
||||
ALLOWED_EXTENSIONS = {'jpg', 'jpeg', 'png'}
|
||||
IMAGE_TYPE_PROFILE = 1
|
||||
IMAGE_TYPE_ICON = 2
|
||||
MAX_DIMENSION = 512
|
||||
|
||||
def allowed_file(filename):
|
||||
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
|
||||
|
||||
@image_api.route('/image/upload', methods=['POST'])
|
||||
def upload():
|
||||
if 'file' not in request.files:
|
||||
return jsonify({'error': 'No file part in the request'}), 400
|
||||
file = request.files['file']
|
||||
if file.filename == '':
|
||||
return jsonify({'error': 'No selected file'}), 400
|
||||
|
||||
image_type = request.form.get('type', None)
|
||||
if not image_type:
|
||||
return jsonify({'error': 'Image type is required'}), 400
|
||||
try:
|
||||
image_type = int(image_type)
|
||||
if image_type not in [IMAGE_TYPE_PROFILE, IMAGE_TYPE_ICON]:
|
||||
return jsonify({'error': 'Invalid image type. Must be 1 or 2'}), 400
|
||||
except ValueError:
|
||||
return jsonify({'error': 'Image type must be an integer'}), 400
|
||||
|
||||
perm = request.form.get('permanent', "false").lower() == 'true'
|
||||
|
||||
if not file or not allowed_file(file.filename):
|
||||
return jsonify({'error': 'Invalid file type or no file selected'}), 400
|
||||
|
||||
try:
|
||||
pil_image = PILImage.open(file.stream)
|
||||
original_format = pil_image.format # store before convert
|
||||
pil_image.verify() # quick integrity check
|
||||
file.stream.seek(0)
|
||||
pil_image = PILImage.open(file.stream).convert('RGBA' if pil_image.mode in ('RGBA', 'LA') else 'RGB')
|
||||
except UnidentifiedImageError:
|
||||
return jsonify({'error': 'Uploaded file is not a valid image'}), 400
|
||||
except Exception:
|
||||
return jsonify({'error': 'Failed to process image'}), 400
|
||||
|
||||
if original_format not in ('JPEG', 'PNG'):
|
||||
return jsonify({'error': 'Only JPEG and PNG images are allowed'}), 400
|
||||
|
||||
# Resize preserving aspect ratio (in-place thumbnail)
|
||||
pil_image.thumbnail((MAX_DIMENSION, MAX_DIMENSION), PILImage.Resampling.LANCZOS)
|
||||
|
||||
format_extension_map = {'JPEG': '.jpg', 'PNG': '.png'}
|
||||
extension = format_extension_map.get(original_format, '.png')
|
||||
|
||||
_id = str(uuid.uuid4())
|
||||
filename = _id + extension
|
||||
filepath = os.path.abspath(os.path.join(UPLOAD_FOLDER, filename))
|
||||
|
||||
try:
|
||||
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
|
||||
# Save with appropriate format
|
||||
save_params = {}
|
||||
if pil_image.format == 'JPEG':
|
||||
save_params['quality'] = 90
|
||||
save_params['optimize'] = True
|
||||
pil_image.save(filepath, format=pil_image.format, **save_params)
|
||||
except Exception:
|
||||
return jsonify({'error': 'Failed to save processed image'}), 500
|
||||
|
||||
image_record = Image(image_type, extension, permanent=perm, id=_id)
|
||||
image_db.insert(image_record.to_dict())
|
||||
|
||||
return jsonify({'message': 'Image uploaded successfully', 'filename': filename, 'id': _id}), 200
|
||||
|
||||
@image_api.route('/image/request/<id>', methods=['GET'])
|
||||
def request_image(id):
|
||||
ImageQuery = Query()
|
||||
image = image_db.get(ImageQuery.id == id)
|
||||
if not image:
|
||||
return jsonify({'error': 'Image not found'}), 404
|
||||
filename = f"{image['id']}{image['extension']}"
|
||||
filepath = os.path.abspath(os.path.join(UPLOAD_FOLDER, filename))
|
||||
if not os.path.exists(filepath):
|
||||
return jsonify({'error': 'File not found'}), 404
|
||||
return send_file(filepath)
|
||||
|
||||
@image_api.route('/image/list', methods=['GET'])
|
||||
def list_images():
|
||||
image_type = request.args.get('type', type=int)
|
||||
ImageQuery = Query()
|
||||
if image_type is not None:
|
||||
if image_type not in [IMAGE_TYPE_PROFILE, IMAGE_TYPE_ICON]:
|
||||
return jsonify({'error': 'Invalid image type'}), 400
|
||||
images = image_db.search(ImageQuery.type == image_type)
|
||||
else:
|
||||
images = image_db.all()
|
||||
image_ids = [img['id'] for img in images]
|
||||
return jsonify({'ids': image_ids, 'count': len(image_ids)}), 200
|
||||
48
api/reward_api.py
Normal file
@@ -0,0 +1,48 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
from tinydb import Query
|
||||
from models.reward import Reward
|
||||
from db.db import reward_db, child_db
|
||||
|
||||
reward_api = Blueprint('reward_api', __name__)
|
||||
|
||||
# Reward endpoints
|
||||
@reward_api.route('/reward/add', methods=['PUT'])
|
||||
def add_reward():
|
||||
data = request.get_json()
|
||||
name = data.get('name')
|
||||
description = data.get('description')
|
||||
cost = data.get('cost')
|
||||
image = data.get('image_id', '')
|
||||
if not name or description is None or cost is None:
|
||||
return jsonify({'error': 'Name, description, and cost are required'}), 400
|
||||
reward = Reward(name, description, cost, image_id=image)
|
||||
reward_db.insert(reward.to_dict())
|
||||
return jsonify({'message': f'Reward {name} added.'}), 201
|
||||
|
||||
@reward_api.route('/reward/<id>', methods=['GET'])
|
||||
def get_reward(id):
|
||||
RewardQuery = Query()
|
||||
result = reward_db.search(RewardQuery.id == id)
|
||||
if not result:
|
||||
return jsonify({'error': 'Reward not found'}), 404
|
||||
return jsonify(result[0]), 200
|
||||
|
||||
@reward_api.route('/reward/list', methods=['GET'])
|
||||
def list_rewards():
|
||||
rewards = reward_db.all()
|
||||
return jsonify({'rewards': rewards}), 200
|
||||
|
||||
@reward_api.route('/reward/<id>', methods=['DELETE'])
|
||||
def delete_reward(id):
|
||||
RewardQuery = Query()
|
||||
removed = reward_db.remove(RewardQuery.id == id)
|
||||
if removed:
|
||||
# remove the reward id from any child's reward list
|
||||
ChildQuery = Query()
|
||||
for child in child_db.all():
|
||||
rewards = child.get('rewards', [])
|
||||
if id in rewards:
|
||||
rewards.remove(id)
|
||||
child_db.update({'rewards': rewards}, ChildQuery.id == child.get('id'))
|
||||
return jsonify({'message': f'Reward {id} deleted.'}), 200
|
||||
return jsonify({'error': 'Reward not found'}), 404
|
||||
14
api/reward_status.py
Normal file
@@ -0,0 +1,14 @@
|
||||
class RewardStatus:
|
||||
def __init__(self, id, name, points_needed, image_id):
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.points_needed = points_needed
|
||||
self.image_id = image_id
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
'points_needed': self.points_needed,
|
||||
'image_id': self.image_id
|
||||
}
|
||||
48
api/task_api.py
Normal file
@@ -0,0 +1,48 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
from tinydb import Query
|
||||
from models.task import Task
|
||||
from db.db import task_db, child_db
|
||||
|
||||
task_api = Blueprint('task_api', __name__)
|
||||
|
||||
# Task endpoints
|
||||
@task_api.route('/task/add', methods=['PUT'])
|
||||
def add_task():
|
||||
data = request.get_json()
|
||||
name = data.get('name')
|
||||
points = data.get('points')
|
||||
is_good = data.get('is_good')
|
||||
image = data.get('image_id', '')
|
||||
if not name or points is None or is_good is None:
|
||||
return jsonify({'error': 'Name, points, and is_good are required'}), 400
|
||||
task = Task(name, points, is_good, image_id=image)
|
||||
task_db.insert(task.to_dict())
|
||||
return jsonify({'message': f'Task {name} added.'}), 201
|
||||
|
||||
@task_api.route('/task/<id>', methods=['GET'])
|
||||
def get_task(id):
|
||||
TaskQuery = Query()
|
||||
result = task_db.search(TaskQuery.id == id)
|
||||
if not result:
|
||||
return jsonify({'error': 'Task not found'}), 404
|
||||
return jsonify(result[0]), 200
|
||||
|
||||
@task_api.route('/task/list', methods=['GET'])
|
||||
def list_tasks():
|
||||
tasks = task_db.all()
|
||||
return jsonify({'tasks': tasks}), 200
|
||||
|
||||
@task_api.route('/task/<id>', methods=['DELETE'])
|
||||
def delete_task(id):
|
||||
TaskQuery = Query()
|
||||
removed = task_db.remove(TaskQuery.id == id)
|
||||
if removed:
|
||||
# remove the task id from any child's task list
|
||||
ChildQuery = Query()
|
||||
for child in child_db.all():
|
||||
tasks = child.get('tasks', [])
|
||||
if id in tasks:
|
||||
tasks.remove(id)
|
||||
child_db.update({'tasks': tasks}, ChildQuery.id == child.get('id'))
|
||||
return jsonify({'message': f'Task {id} deleted.'}), 200
|
||||
return jsonify({'error': 'Task not found'}), 404
|
||||
95
db/db.py
Normal file
@@ -0,0 +1,95 @@
|
||||
# python
|
||||
import os
|
||||
import threading
|
||||
from tinydb import TinyDB
|
||||
|
||||
DB_ENV = os.environ.get('DB_ENV', 'prod')
|
||||
base_dir = os.path.dirname(__file__)
|
||||
|
||||
class LockedTable:
|
||||
"""
|
||||
Thread-safe wrapper around a TinyDB table. All callable attribute access
|
||||
is wrapped to acquire a reentrant lock while calling the underlying method.
|
||||
Non-callable attributes are returned directly.
|
||||
"""
|
||||
def __init__(self, table):
|
||||
self._table = table
|
||||
self._lock = threading.RLock()
|
||||
|
||||
def __getattr__(self, name):
|
||||
# avoid proxying internal attrs
|
||||
if name in ('_table', '_lock'):
|
||||
return super().__getattribute__(name)
|
||||
|
||||
attr = getattr(self._table, name)
|
||||
|
||||
if callable(attr):
|
||||
def locked_call(*args, **kwargs):
|
||||
with self._lock:
|
||||
return attr(*args, **kwargs)
|
||||
return locked_call
|
||||
return attr
|
||||
|
||||
# convenience explicit methods (ensure these are class methods, not top-level)
|
||||
def insert(self, *args, **kwargs):
|
||||
with self._lock:
|
||||
return self._table.insert(*args, **kwargs)
|
||||
|
||||
def insert_multiple(self, *args, **kwargs):
|
||||
with self._lock:
|
||||
return self._table.insert_multiple(*args, **kwargs)
|
||||
|
||||
def search(self, *args, **kwargs):
|
||||
with self._lock:
|
||||
return self._table.search(*args, **kwargs)
|
||||
|
||||
def get(self, *args, **kwargs):
|
||||
with self._lock:
|
||||
return self._table.get(*args, **kwargs)
|
||||
|
||||
def all(self, *args, **kwargs):
|
||||
with self._lock:
|
||||
return self._table.all(*args, **kwargs)
|
||||
|
||||
def remove(self, *args, **kwargs):
|
||||
with self._lock:
|
||||
return self._table.remove(*args, **kwargs)
|
||||
|
||||
def update(self, *args, **kwargs):
|
||||
with self._lock:
|
||||
return self._table.update(*args, **kwargs)
|
||||
|
||||
def truncate(self):
|
||||
with self._lock:
|
||||
return self._table.truncate()
|
||||
|
||||
# Setup DB files next to this module
|
||||
|
||||
if DB_ENV == 'test':
|
||||
child_path = os.path.join(base_dir, 'test_children.json')
|
||||
task_path = os.path.join(base_dir, 'test_tasks.json')
|
||||
reward_path = os.path.join(base_dir, 'test_rewards.json')
|
||||
image_path = os.path.join(base_dir, 'test_images.json')
|
||||
else:
|
||||
child_path = os.path.join(base_dir, 'children.json')
|
||||
task_path = os.path.join(base_dir, 'tasks.json')
|
||||
reward_path = os.path.join(base_dir, 'rewards.json')
|
||||
image_path = os.path.join(base_dir, 'images.json')
|
||||
|
||||
# Use separate TinyDB instances/files for each collection
|
||||
_child_db = TinyDB(child_path, indent=2)
|
||||
_task_db = TinyDB(task_path, indent=2)
|
||||
_reward_db = TinyDB(reward_path, indent=2)
|
||||
_image_db = TinyDB(image_path, indent=2)
|
||||
|
||||
# Expose table objects wrapped with locking
|
||||
child_db = LockedTable(_child_db)
|
||||
task_db = LockedTable(_task_db)
|
||||
reward_db = LockedTable(_reward_db)
|
||||
image_db = LockedTable(_image_db)
|
||||
|
||||
if DB_ENV == 'test':
|
||||
child_db.truncate()
|
||||
task_db.truncate()
|
||||
reward_db.truncate()
|
||||
image_db.truncate()
|
||||
108
db/debug.py
Normal file
@@ -0,0 +1,108 @@
|
||||
# python
|
||||
# File: db/debug.py
|
||||
|
||||
import random
|
||||
from models.child import Child
|
||||
from models.task import Task
|
||||
from models.reward import Reward
|
||||
from models.image import Image
|
||||
from db.db import child_db, task_db, reward_db, image_db
|
||||
|
||||
def populate_debug_data(clear_existing=True, seed=42):
|
||||
"""
|
||||
Populate DBs with dummy data:
|
||||
- 2 children
|
||||
- 8 tasks
|
||||
- 4 rewards
|
||||
Each child gets 3 unique tasks and 2 unique rewards (unique within that child).
|
||||
Returns a dict with inserted records.
|
||||
"""
|
||||
random.seed(seed)
|
||||
|
||||
# Optionally clear existing data
|
||||
if clear_existing:
|
||||
try:
|
||||
child_db.truncate()
|
||||
task_db.truncate()
|
||||
reward_db.truncate()
|
||||
image_db.truncate()
|
||||
except Exception:
|
||||
# fallback: remove all docs by id if truncate isn't available
|
||||
from tinydb import Query
|
||||
for doc in child_db.all():
|
||||
child_db.remove(Query().id == doc.get('id'))
|
||||
for doc in task_db.all():
|
||||
task_db.remove(Query().id == doc.get('id'))
|
||||
for doc in reward_db.all():
|
||||
reward_db.remove(Query().id == doc.get('id'))
|
||||
for doc in image_db.all():
|
||||
image_db.remove(Query().id == doc.get('id'))
|
||||
|
||||
# Create 8 tasks
|
||||
task_defs = [
|
||||
("Make Bed", 2, True, 'a86feb1f-be65-4ca2-bdb5-9223ffbd6779'),
|
||||
("Brush Teeth", 1, True, 'a86feb1f-be65-4ca2-bdb5-9223ffbd6779'),
|
||||
("Do Homework", 5, True, 'a86feb1f-be65-4ca2-bdb5-9223ffbd6779'),
|
||||
("Clean Room", 3, True),
|
||||
("Practice Piano", 4, True),
|
||||
("Feed Pet", 1, True, 'a86feb1f-be65-4ca2-bdb5-9223ffbd6779'),
|
||||
("Take Out Trash", 2, True),
|
||||
("Set Table", 1, True),
|
||||
("Misc Task 1", 1, False),
|
||||
("Misc Task 2", 2, False, 'a86feb1f-be65-4ca2-bdb5-9223ffbd6779'),
|
||||
("Misc Task 3", 3, False),
|
||||
("Misc Task 4", 4, False, 'a86feb1f-be65-4ca2-bdb5-9223ffbd6779'),
|
||||
("Misc Task 5", 5, True),
|
||||
]
|
||||
tasks = []
|
||||
for td in task_defs:
|
||||
if len(td) == 4:
|
||||
name, points, is_good, image = td
|
||||
else:
|
||||
name, points, is_good = td
|
||||
image = None
|
||||
t = Task(name=name, points=points, is_good=is_good, image_id=image)
|
||||
task_db.insert(t.to_dict())
|
||||
tasks.append(t.to_dict())
|
||||
|
||||
# Create 4 rewards
|
||||
reward_defs = [
|
||||
("Sticker Pack", "Fun stickers", 3,'a86feb1f-be65-4ca2-bdb5-9223ffbd6779'),
|
||||
("Extra Screen Time", "30 minutes", 8, 'a86feb1f-be65-4ca2-bdb5-9223ffbd6779'),
|
||||
("New Toy", "Small toy", 12, 'a86feb1f-be65-4ca2-bdb5-9223ffbd6779'),
|
||||
("Ice Cream", "One scoop", 5, 'a86feb1f-be65-4ca2-bdb5-9223ffbd6779'),
|
||||
]
|
||||
rewards = []
|
||||
for name, desc, cost, image in reward_defs:
|
||||
r = Reward(name=name, description=desc, cost=cost, image_id=image)
|
||||
reward_db.insert(r.to_dict())
|
||||
rewards.append(r.to_dict())
|
||||
|
||||
image_db.insert(Image(1, '.png', True, id='a86feb1f-be65-4ca2-bdb5-9223ffbd6779').to_dict())
|
||||
|
||||
# Create 2 children and assign unique tasks/rewards per child
|
||||
children = []
|
||||
task_ids = [t['id'] for t in tasks]
|
||||
reward_ids = [r['id'] for r in rewards]
|
||||
child_names = [("Child One", 8, "boy01"), ("Child Two", 10, "girl01")]
|
||||
|
||||
for name, age, image in child_names:
|
||||
chosen_tasks = random.sample(task_ids, 11)
|
||||
chosen_rewards = random.sample(reward_ids, 2)
|
||||
points = random.randint(0, 15)
|
||||
c = Child(name=name, age=age, tasks=chosen_tasks, rewards=chosen_rewards, points=points, image_id=image)
|
||||
child_db.insert(c.to_dict())
|
||||
children.append(c.to_dict())
|
||||
|
||||
return {
|
||||
"children": children,
|
||||
"tasks": tasks,
|
||||
"rewards": rewards
|
||||
}
|
||||
|
||||
if __name__ == "__main__":
|
||||
result = populate_debug_data(clear_existing=True)
|
||||
print("Inserted debug data:")
|
||||
print(f"Children: {[c['name'] for c in result['children']]}")
|
||||
print(f"Tasks: {[t['name'] for t in result['tasks']]}")
|
||||
print(f"Rewards: {[r['name'] for r in result['rewards']]}")
|
||||
86
db/default.py
Normal file
@@ -0,0 +1,86 @@
|
||||
# python
|
||||
# File: db/debug.py
|
||||
|
||||
import random
|
||||
from models.child import Child
|
||||
from models.task import Task
|
||||
from models.reward import Reward
|
||||
from models.image import Image
|
||||
from tinydb import Query
|
||||
from db.db import child_db, task_db, reward_db, image_db
|
||||
from api.image_api import IMAGE_TYPE_ICON, IMAGE_TYPE_PROFILE
|
||||
|
||||
def populate_default_data():
|
||||
# Create tasks
|
||||
task_defs = [
|
||||
('default_001', "Be Respectful", 2, True, ''),
|
||||
('default_002', "Brush Teeth", 2, True, ''),
|
||||
('default_003', "Go To Bed", 2, True, ''),
|
||||
('default_004', "Do What You Are Told", 2, True, ''),
|
||||
('default_005', "Make Your Bed", 2, True, ''),
|
||||
('default_006', "Do Homework", 2, True, ''),
|
||||
]
|
||||
tasks = []
|
||||
for _id, name, points, is_good, image in task_defs:
|
||||
t = Task(name=name, points=points, is_good=is_good, image_id=image, id=_id)
|
||||
tq = Query()
|
||||
_result = task_db.search(tq.id == _id)
|
||||
if not _result:
|
||||
task_db.insert(t.to_dict())
|
||||
else:
|
||||
task_db.update(t.to_dict(), tq.id == _id)
|
||||
tasks.append(t.to_dict())
|
||||
|
||||
# Create 4 rewards
|
||||
reward_defs = [
|
||||
('default_001', "Special Trip", "Go to a park or a museum", 3,''),
|
||||
('default_002', "Money", "Money is always nice", 8, ''),
|
||||
('default_003', "Choose Dinner", "What shall we eat?", 12, 'meal'),
|
||||
('default_004', "Tablet Time", "Play your games", 5, 'tablet'),
|
||||
('default_005', "Computer Time", "Minecraft or Roblox?", 5, 'computer-game'),
|
||||
('default_006', "TV Time", "Too much is bad for you.", 5, ''),
|
||||
]
|
||||
rewards = []
|
||||
for _id, name, desc, cost, image in reward_defs:
|
||||
r = Reward(name=name, description=desc, cost=cost, image_id=image, id=_id)
|
||||
rq = Query()
|
||||
_result = reward_db.search(rq.id == _id)
|
||||
if not _result:
|
||||
reward_db.insert(r.to_dict())
|
||||
else:
|
||||
reward_db.update(r.to_dict(), rq.id == _id)
|
||||
rewards.append(r.to_dict())
|
||||
|
||||
image_defs = [
|
||||
('computer-game', IMAGE_TYPE_ICON, '.png', True),
|
||||
('ice-cream', IMAGE_TYPE_ICON, '.png', True),
|
||||
('meal', IMAGE_TYPE_ICON, '.png', True),
|
||||
('playground', IMAGE_TYPE_ICON, '.png', True),
|
||||
('tablet', IMAGE_TYPE_ICON, '.png', True),
|
||||
('boy01', IMAGE_TYPE_PROFILE, '.png', True),
|
||||
('girl01', IMAGE_TYPE_PROFILE, '.png', True),
|
||||
('girl02', IMAGE_TYPE_PROFILE, '.png', True),
|
||||
('boy02', IMAGE_TYPE_PROFILE, '.png', True),
|
||||
('boy03', IMAGE_TYPE_PROFILE, '.png', True),
|
||||
('girl03', IMAGE_TYPE_PROFILE, '.png', True),
|
||||
('boy04', IMAGE_TYPE_PROFILE, '.png', True),
|
||||
('girl04', IMAGE_TYPE_PROFILE, '.png', True),
|
||||
]
|
||||
images = []
|
||||
for _id, _type, ext, perm in image_defs:
|
||||
iq = Query()
|
||||
_result = image_db.search(iq.id == _id)
|
||||
if not _result:
|
||||
image_db.insert(Image(_type, ext, perm, id=_id).to_dict())
|
||||
else:
|
||||
image_db.update(Image(_type, ext, perm, id=_id).to_dict(), iq.id == _id)
|
||||
images.append(Image(_type, ext, perm, id=_id).to_dict())
|
||||
|
||||
return {
|
||||
"images": images,
|
||||
"tasks": tasks,
|
||||
"rewards": rewards
|
||||
}
|
||||
|
||||
if __name__ == "__main__":
|
||||
result = populate_default_data()
|
||||
19
main.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from flask import Flask
|
||||
from flask_cors import CORS
|
||||
from api.child_api import child_api
|
||||
from api.reward_api import reward_api
|
||||
from api.task_api import task_api
|
||||
from api.image_api import image_api
|
||||
|
||||
app = Flask(__name__)
|
||||
#CORS(app, resources={r"/api/*": {"origins": ["http://localhost:3000", "http://localhost:5173"]}})
|
||||
app.register_blueprint(child_api)
|
||||
app.register_blueprint(reward_api)
|
||||
app.register_blueprint(task_api)
|
||||
app.register_blueprint(image_api)
|
||||
CORS(app)
|
||||
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(debug=False, host='0.0.0.0', port=5000)
|
||||
39
models/child.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from dataclasses import dataclass, field
|
||||
import uuid
|
||||
|
||||
@dataclass
|
||||
class Child:
|
||||
name: str
|
||||
age: int | None = None
|
||||
tasks: list[str] = field(default_factory=list)
|
||||
rewards: list[str] = field(default_factory=list)
|
||||
points: int = 0
|
||||
image_id: str | None = None
|
||||
id: str | None = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.id is None:
|
||||
self.id = str(uuid.uuid4())
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict):
|
||||
return cls(
|
||||
id=d.get('id'),
|
||||
name=d.get('name'),
|
||||
age=d.get('age'),
|
||||
tasks=d.get('tasks', []),
|
||||
rewards=d.get('rewards', []),
|
||||
points=d.get('points', 0),
|
||||
image_id=d.get('image_id')
|
||||
)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
'age': self.age,
|
||||
'tasks': self.tasks,
|
||||
'rewards': self.rewards,
|
||||
'points': self.points,
|
||||
'image_id': self.image_id
|
||||
}
|
||||
30
models/image.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from dataclasses import dataclass
|
||||
import uuid
|
||||
|
||||
@dataclass
|
||||
class Image:
|
||||
type: int
|
||||
extension: str
|
||||
permanent: bool = False
|
||||
id: str | None = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.id is None:
|
||||
self.id = str(uuid.uuid4())
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict):
|
||||
return cls(
|
||||
id=d.get('id'),
|
||||
type=d.get('type'),
|
||||
permanent=d.get('permanent', False),
|
||||
extension=d.get('extension')
|
||||
)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'type': self.type,
|
||||
'permanent': self.permanent,
|
||||
'extension': self.extension
|
||||
}
|
||||
33
models/reward.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from dataclasses import dataclass
|
||||
import uuid
|
||||
|
||||
@dataclass
|
||||
class Reward:
|
||||
name: str
|
||||
description: str
|
||||
cost: int
|
||||
image_id: str | None = None
|
||||
id: str | None = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.id is None:
|
||||
self.id = str(uuid.uuid4())
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict):
|
||||
return cls(
|
||||
id=d.get('id'),
|
||||
name=d.get('name'),
|
||||
description=d.get('description'),
|
||||
cost=d.get('cost', 0),
|
||||
image_id=d.get('image_id')
|
||||
)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
'description': self.description,
|
||||
'cost': self.cost,
|
||||
'image_id': self.image_id
|
||||
}
|
||||
33
models/task.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from dataclasses import dataclass
|
||||
import uuid
|
||||
|
||||
@dataclass
|
||||
class Task:
|
||||
name: str
|
||||
points: int
|
||||
is_good: bool
|
||||
image_id: str | None = None
|
||||
id: str | None = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.id is None:
|
||||
self.id = str(uuid.uuid4())
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict):
|
||||
return cls(
|
||||
id=d.get('id'),
|
||||
name=d.get('name'),
|
||||
points=d.get('points', 0),
|
||||
is_good=d.get('is_good', True),
|
||||
image_id=d.get('image_id')
|
||||
)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
'points': self.points,
|
||||
'is_good': self.is_good,
|
||||
'image_id': self.image_id
|
||||
}
|
||||
BIN
resources/images/0c7b2f66-ab7a-42ae-a96e-0fd5e4d842d6.jpg
Normal file
|
After Width: | Height: | Size: 2.6 MiB |
BIN
resources/images/2c2605ce-23c1-4dda-8f60-4702b334c946.jpg
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
resources/images/2d8f361b-ed8e-4a82-9a64-f67adb82168e.png
Normal file
|
After Width: | Height: | Size: 512 KiB |
BIN
resources/images/boy01.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
resources/images/boy02.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
resources/images/boy03.png
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
resources/images/boy04.png
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
resources/images/cacad6b7-9186-4dd6-9969-5682fefb9bd7.png
Normal file
|
After Width: | Height: | Size: 247 KiB |
BIN
resources/images/computer-game.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
resources/images/d55998b5-c982-4caa-891e-6c62911afc34.png
Normal file
|
After Width: | Height: | Size: 281 KiB |
BIN
resources/images/fa141374-aa71-4fab-8204-1b5af96d8715.png
Normal file
|
After Width: | Height: | Size: 497 KiB |
BIN
resources/images/fc2ad460-ca11-45aa-b27d-16c6b7557afb.png
Normal file
|
After Width: | Height: | Size: 293 KiB |
BIN
resources/images/girl01.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
resources/images/girl02.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
resources/images/girl03.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
resources/images/girl04.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
resources/images/ice-cream.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
resources/images/meal.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
resources/images/playground.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
resources/images/tablet.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
6
tests/conftest.py
Normal file
@@ -0,0 +1,6 @@
|
||||
import os
|
||||
import pytest
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def set_test_db_env():
|
||||
os.environ['DB_ENV'] = 'test'
|
||||
179
tests/test_child_api.py
Normal file
@@ -0,0 +1,179 @@
|
||||
import pytest
|
||||
import os
|
||||
from flask import Flask
|
||||
from api.child_api import child_api
|
||||
from db.db import child_db, reward_db, task_db
|
||||
from tinydb import Query
|
||||
from models.child import Child
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
app = Flask(__name__)
|
||||
app.register_blueprint(child_api)
|
||||
app.config['TESTING'] = True
|
||||
with app.test_client() as client:
|
||||
yield client
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def cleanup_db():
|
||||
yield
|
||||
child_db.close()
|
||||
reward_db.close()
|
||||
task_db.close()
|
||||
if os.path.exists('children.json'):
|
||||
os.remove('children.json')
|
||||
if os.path.exists('rewards.json'):
|
||||
os.remove('rewards.json')
|
||||
if os.path.exists('tasks.json'):
|
||||
os.remove('tasks.json')
|
||||
|
||||
def test_add_child(client):
|
||||
response = client.put('/child/add', json={'name': 'Alice', 'age': 8})
|
||||
assert response.status_code == 201
|
||||
assert b'Child Alice added.' in response.data
|
||||
children = child_db.all()
|
||||
assert any(c.get('name') == 'Alice' and c.get('age') == 8 and c.get('image_id') == 'boy01' for c in children)
|
||||
|
||||
response = client.put('/child/add', json={'name': 'Mike', 'age': 4, 'image_id': 'girl02'})
|
||||
assert response.status_code == 201
|
||||
assert b'Child Mike added.' in response.data
|
||||
children = child_db.all()
|
||||
assert any(c.get('name') == 'Mike' and c.get('age') == 4 and c.get('image_id') == 'girl02' for c in children)
|
||||
|
||||
def test_list_children(client):
|
||||
child_db.truncate()
|
||||
child_db.insert(Child(name='Alice', age=8, image_id="boy01").to_dict())
|
||||
child_db.insert(Child(name='Mike', age=4, image_id="boy01").to_dict())
|
||||
response = client.get('/child/list')
|
||||
assert response.status_code == 200
|
||||
data = response.json
|
||||
assert len(data['children']) == 2
|
||||
|
||||
def test_assign_and_remove_task(client):
|
||||
client.put('/child/add', json={'name': 'Bob', 'age': 10})
|
||||
children = client.get('/child/list').get_json()['children']
|
||||
child_id = children[0]['id']
|
||||
response = client.post(f'/child/{child_id}/assign-task', json={'task_id': 'task123'})
|
||||
assert response.status_code == 200
|
||||
ChildQuery = Query()
|
||||
child = child_db.search(ChildQuery.id == child_id)[0]
|
||||
assert 'task123' in child.get('tasks', [])
|
||||
response = client.post(f'/child/{child_id}/remove-task', json={'task_id': 'task123'})
|
||||
assert response.status_code == 200
|
||||
child = child_db.search(ChildQuery.id == child_id)[0]
|
||||
assert 'task123' not in child.get('tasks', [])
|
||||
|
||||
def test_get_child_not_found(client):
|
||||
response = client.get('/child/nonexistent-id')
|
||||
assert response.status_code == 404
|
||||
assert b'Child not found' in response.data
|
||||
|
||||
def test_remove_task_not_found(client):
|
||||
response = client.post('/child/nonexistent-id/remove-task', json={'task_id': 'task123'})
|
||||
assert response.status_code == 404
|
||||
assert b'Child not found' in response.data
|
||||
|
||||
def test_remove_reward_not_found(client):
|
||||
response = client.post('/child/nonexistent-id/remove-reward', json={'reward_id': 'reward123'})
|
||||
assert response.status_code == 404
|
||||
assert b'Child not found' in response.data
|
||||
|
||||
def test_affordable_rewards(client):
|
||||
reward_db.insert({'id': 'r_cheep', 'name': 'Sticker', 'cost': 5})
|
||||
reward_db.insert({'id': 'r_exp', 'name': 'Bike', 'cost': 20})
|
||||
client.put('/child/add', json={'name': 'Charlie', 'age': 9})
|
||||
children = client.get('/child/list').get_json()['children']
|
||||
child_id = next((c['id'] for c in children if c.get('name') == 'Charlie'), children[0]['id'])
|
||||
ChildQuery = Query()
|
||||
child_db.update({'points': 10}, ChildQuery.id == child_id)
|
||||
client.post(f'/child/{child_id}/assign-reward', json={'reward_id': 'r_cheep'})
|
||||
client.post(f'/child/{child_id}/assign-reward', json={'reward_id': 'r_exp'})
|
||||
resp = client.get(f'/child/{child_id}/affordable-rewards')
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
affordable_ids = [r['id'] for r in data['affordable_rewards']]
|
||||
assert 'r_cheep' in affordable_ids and 'r_exp' not in affordable_ids
|
||||
|
||||
def test_reward_status(client):
|
||||
reward_db.insert({'id': 'r1', 'name': 'Candy', 'cost': 3, 'image': 'candy.png'})
|
||||
reward_db.insert({'id': 'r2', 'name': 'Game', 'cost': 8, 'image': 'game.png'})
|
||||
reward_db.insert({'id': 'r3', 'name': 'Trip', 'cost': 15, 'image': 'trip.png'})
|
||||
client.put('/child/add', json={'name': 'Dana', 'age': 11})
|
||||
children = client.get('/child/list').get_json()['children']
|
||||
child_id = next((c['id'] for c in children if c.get('name') == 'Dana'), children[0]['id'])
|
||||
ChildQuery = Query()
|
||||
child_db.update({'points': 7}, ChildQuery.id == child_id)
|
||||
for rid in ['r1', 'r2', 'r3']:
|
||||
client.post(f'/child/{child_id}/assign-reward', json={'reward_id': rid})
|
||||
resp = client.get(f'/child/{child_id}/reward-status')
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
mapping = {s['id']: s['points_needed'] for s in data['reward_status']}
|
||||
assert mapping['r1'] == 0 and mapping['r2'] == 1 and mapping['r3'] == 8
|
||||
|
||||
def test_list_child_tasks_returns_tasks(client):
|
||||
task_db.insert({'id': 't_list_1', 'name': 'Task One', 'points': 2, 'is_good': True})
|
||||
task_db.insert({'id': 't_list_2', 'name': 'Task Two', 'points': 3, 'is_good': False})
|
||||
child_db.insert({
|
||||
'id': 'child_list_1',
|
||||
'name': 'Eve',
|
||||
'age': 8,
|
||||
'points': 0,
|
||||
'tasks': ['t_list_1', 't_list_2', 't_missing'],
|
||||
'rewards': []
|
||||
})
|
||||
resp = client.get('/child/child_list_1/list-tasks')
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
returned_ids = {t['id'] for t in data['child_tasks']}
|
||||
assert returned_ids == {'t_list_1', 't_list_2'}
|
||||
for t in data['child_tasks']:
|
||||
assert 'name' in t and 'points' in t and 'is_good' in t
|
||||
|
||||
def test_list_assignable_tasks_returns_expected_ids(client):
|
||||
child_db.truncate()
|
||||
task_db.truncate()
|
||||
task_db.insert({'id': 'tA', 'name': 'Task A', 'points': 1, 'is_good': True})
|
||||
task_db.insert({'id': 'tB', 'name': 'Task B', 'points': 2, 'is_good': True})
|
||||
task_db.insert({'id': 'tC', 'name': 'Task C', 'points': 3, 'is_good': False})
|
||||
client.put('/child/add', json={'name': 'Zoe', 'age': 7})
|
||||
child_id = client.get('/child/list').get_json()['children'][0]['id']
|
||||
client.post(f'/child/{child_id}/assign-task', json={'task_id': 'tA'})
|
||||
client.post(f'/child/{child_id}/assign-task', json={'task_id': 'tC'})
|
||||
resp = client.get(f'/child/{child_id}/list-assignable-tasks')
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert len(data['assignable_tasks']) == 1
|
||||
assert data['assignable_tasks'][0]['id'] == 'tB'
|
||||
assert data['count'] == 1
|
||||
|
||||
def test_list_assignable_tasks_when_none_assigned(client):
|
||||
child_db.truncate()
|
||||
task_db.truncate()
|
||||
ids = ['t1', 't2', 't3']
|
||||
for i, tid in enumerate(ids, 1):
|
||||
task_db.insert({'id': tid, 'name': f'Task {i}', 'points': i, 'is_good': True})
|
||||
client.put('/child/add', json={'name': 'Liam', 'age': 6})
|
||||
child_id = client.get('/child/list').get_json()['children'][0]['id']
|
||||
resp = client.get(f'/child/{child_id}/list-assignable-tasks')
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
returned_ids = {t['id'] for t in data['assignable_tasks']}
|
||||
assert returned_ids == set(ids)
|
||||
assert data['count'] == len(ids)
|
||||
|
||||
def test_list_assignable_tasks_empty_task_db(client):
|
||||
child_db.truncate()
|
||||
task_db.truncate()
|
||||
client.put('/child/add', json={'name': 'Mia', 'age': 5})
|
||||
child_id = client.get('/child/list').get_json()['children'][0]['id']
|
||||
resp = client.get(f'/child/{child_id}/list-assignable-tasks')
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data['assignable_tasks'] == []
|
||||
assert data['count'] == 0
|
||||
|
||||
def test_list_assignable_tasks_child_not_found(client):
|
||||
resp = client.get('/child/does-not-exist/list-assignable-tasks')
|
||||
assert resp.status_code == 404
|
||||
assert b'Child not found' in resp.data
|
||||
180
tests/test_image_api.py
Normal file
@@ -0,0 +1,180 @@
|
||||
# python
|
||||
import io
|
||||
import os
|
||||
from PIL import Image as PILImage
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from api.image_api import image_api, UPLOAD_FOLDER
|
||||
from db.db import image_db
|
||||
|
||||
IMAGE_TYPE_PROFILE = 1
|
||||
IMAGE_TYPE_ICON = 2
|
||||
MAX_DIMENSION = 512
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
app = Flask(__name__)
|
||||
app.register_blueprint(image_api)
|
||||
app.config['TESTING'] = True
|
||||
with app.test_client() as c:
|
||||
yield c
|
||||
for f in os.listdir(UPLOAD_FOLDER):
|
||||
os.remove(os.path.join(UPLOAD_FOLDER, f))
|
||||
image_db.truncate()
|
||||
|
||||
def make_image_bytes(w, h, mode='RGB', color=(255, 0, 0, 255), fmt='PNG'):
|
||||
img = PILImage.new(mode, (w, h), color)
|
||||
bio = io.BytesIO()
|
||||
img.save(bio, format=fmt)
|
||||
bio.seek(0)
|
||||
return bio
|
||||
|
||||
def list_saved_files():
|
||||
return [f for f in os.listdir(UPLOAD_FOLDER) if os.path.isfile(os.path.join(UPLOAD_FOLDER, f))]
|
||||
|
||||
def test_upload_missing_type(client):
|
||||
img = make_image_bytes(100, 100, fmt='PNG')
|
||||
data = {'file': (img, 'test.png')}
|
||||
resp = client.post('/image/upload', data=data, content_type='multipart/form-data')
|
||||
assert resp.status_code == 400
|
||||
assert b'Image type is required' in resp.data
|
||||
|
||||
def test_upload_invalid_type_value(client):
|
||||
img = make_image_bytes(50, 50, fmt='PNG')
|
||||
data = {'file': (img, 'test.png'), 'type': '3'}
|
||||
resp = client.post('/image/upload', data=data, content_type='multipart/form-data')
|
||||
assert resp.status_code == 400
|
||||
assert b'Invalid image type. Must be 1 or 2' in resp.data
|
||||
|
||||
def test_upload_non_integer_type(client):
|
||||
img = make_image_bytes(50, 50, fmt='PNG')
|
||||
data = {'file': (img, 'test.png'), 'type': 'abc'}
|
||||
resp = client.post('/image/upload', data=data, content_type='multipart/form-data')
|
||||
assert resp.status_code == 400
|
||||
assert b'Image type must be an integer' in resp.data
|
||||
|
||||
def test_upload_valid_png_with_id(client):
|
||||
image_db.truncate()
|
||||
img = make_image_bytes(120, 80, fmt='PNG')
|
||||
data = {'file': (img, 'sample.png'), 'type': str(IMAGE_TYPE_ICON), 'permanent': 'true'}
|
||||
resp = client.post('/image/upload', data=data, content_type='multipart/form-data')
|
||||
assert resp.status_code == 200
|
||||
j = resp.get_json()
|
||||
assert 'id' in j and j['id']
|
||||
assert j['message'] == 'Image uploaded successfully'
|
||||
# DB entry
|
||||
records = image_db.all()
|
||||
assert len(records) == 1
|
||||
rec = records[0]
|
||||
assert rec['id'] == j['id']
|
||||
assert rec['permanent'] is True
|
||||
|
||||
def test_upload_valid_jpeg_extension_mapping(client):
|
||||
image_db.truncate()
|
||||
img = make_image_bytes(100, 60, mode='RGB', fmt='JPEG')
|
||||
data = {'file': (img, 'photo.jpg'), 'type': str(IMAGE_TYPE_PROFILE)}
|
||||
resp = client.post('/image/upload', data=data, content_type='multipart/form-data')
|
||||
assert resp.status_code == 200
|
||||
j = resp.get_json()
|
||||
# Expected extension should be .jpg (this will fail with current code if format lost)
|
||||
filename = j['filename']
|
||||
assert filename.endswith('.jpg'), "JPEG should be saved with .jpg extension (code may be using None format)"
|
||||
path = os.path.join(UPLOAD_FOLDER, filename)
|
||||
assert os.path.exists(path)
|
||||
|
||||
def test_upload_png_alpha_preserved(client):
|
||||
image_db.truncate()
|
||||
img = make_image_bytes(64, 64, mode='RGBA', color=(10, 20, 30, 128), fmt='PNG')
|
||||
data = {'file': (img, 'alpha.png'), 'type': str(IMAGE_TYPE_ICON)}
|
||||
resp = client.post('/image/upload', data=data, content_type='multipart/form-data')
|
||||
assert resp.status_code == 200
|
||||
j = resp.get_json()
|
||||
path = os.path.join(UPLOAD_FOLDER, j['filename'])
|
||||
with PILImage.open(path) as saved:
|
||||
# Alpha should exist (mode RGBA); if conversion changed it incorrectly this fails
|
||||
assert saved.mode in ('RGBA', 'LA')
|
||||
|
||||
def test_upload_large_image_resized(client):
|
||||
image_db.truncate()
|
||||
img = make_image_bytes(2000, 1500, fmt='PNG')
|
||||
data = {'file': (img, 'large.png'), 'type': str(IMAGE_TYPE_PROFILE)}
|
||||
resp = client.post('/image/upload', data=data, content_type='multipart/form-data')
|
||||
assert resp.status_code == 200
|
||||
j = resp.get_json()
|
||||
path = os.path.join(UPLOAD_FOLDER, j['filename'])
|
||||
with PILImage.open(path) as saved:
|
||||
assert saved.width <= MAX_DIMENSION
|
||||
assert saved.height <= MAX_DIMENSION
|
||||
|
||||
def test_upload_invalid_image_content(client):
|
||||
bogus = io.BytesIO(b'notanimage')
|
||||
data = {'file': (bogus, 'bad.png'), 'type': str(IMAGE_TYPE_ICON)}
|
||||
resp = client.post('/image/upload', data=data, content_type='multipart/form-data')
|
||||
assert resp.status_code == 400
|
||||
# Could be 'Uploaded file is not a valid image' or 'Failed to process image'
|
||||
assert b'valid image' in resp.data or b'Failed to process image' in resp.data
|
||||
|
||||
def test_upload_invalid_extension(client):
|
||||
image_db.truncate()
|
||||
img = make_image_bytes(40, 40, fmt='PNG')
|
||||
data = {'file': (img, 'note.gif'), 'type': str(IMAGE_TYPE_ICON)}
|
||||
resp = client.post('/image/upload', data=data, content_type='multipart/form-data')
|
||||
assert resp.status_code == 400
|
||||
assert b'Invalid file type' in resp.data or b'Invalid file type or no file selected' in resp.data
|
||||
|
||||
def test_request_image_success(client):
|
||||
image_db.truncate()
|
||||
img = make_image_bytes(30, 30, fmt='PNG')
|
||||
data = {'file': (img, 'r.png'), 'type': str(IMAGE_TYPE_ICON)}
|
||||
up = client.post('/image/upload', data=data, content_type='multipart/form-data')
|
||||
assert up.status_code == 200
|
||||
recs = image_db.all()
|
||||
image_id = recs[0]['id']
|
||||
resp = client.get(f'/image/request/{image_id}')
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_request_image_not_found(client):
|
||||
resp = client.get('/image/request/missing-id')
|
||||
assert resp.status_code == 404
|
||||
assert b'Image not found' in resp.data
|
||||
|
||||
def test_list_images_filter_type(client):
|
||||
image_db.truncate()
|
||||
# Upload type 1
|
||||
for _ in range(2):
|
||||
img = make_image_bytes(20, 20, fmt='PNG')
|
||||
client.post('/image/upload', data={'file': (img, 'a.png'), 'type': '1'}, content_type='multipart/form-data')
|
||||
# Upload type 2
|
||||
for _ in range(3):
|
||||
img = make_image_bytes(25, 25, fmt='PNG')
|
||||
client.post('/image/upload', data={'file': (img, 'b.png'), 'type': '2'}, content_type='multipart/form-data')
|
||||
|
||||
resp = client.get('/image/list?type=2')
|
||||
assert resp.status_code == 200
|
||||
j = resp.get_json()
|
||||
assert j['count'] == 3
|
||||
|
||||
def test_list_images_invalid_type_query(client):
|
||||
resp = client.get('/image/list?type=99')
|
||||
assert resp.status_code == 400
|
||||
assert b'Invalid image type' in resp.data
|
||||
|
||||
def test_list_images_all(client):
|
||||
image_db.truncate()
|
||||
for _ in range(4):
|
||||
img = make_image_bytes(10, 10, fmt='PNG')
|
||||
client.post('/image/upload', data={'file': (img, 'x.png'), 'type': '2'}, content_type='multipart/form-data')
|
||||
resp = client.get('/image/list')
|
||||
assert resp.status_code == 200
|
||||
j = resp.get_json()
|
||||
assert j['count'] == 4
|
||||
assert len(j['ids']) == 4
|
||||
|
||||
def test_permanent_flag_false_default(client):
|
||||
image_db.truncate()
|
||||
img = make_image_bytes(32, 32, fmt='PNG')
|
||||
data = {'file': (img, 't.png'), 'type': '1'}
|
||||
resp = client.post('/image/upload', data=data, content_type='multipart/form-data')
|
||||
assert resp.status_code == 200
|
||||
recs = image_db.all()
|
||||
assert recs[0]['permanent'] is False
|
||||
83
tests/test_reward_api.py
Normal file
@@ -0,0 +1,83 @@
|
||||
import pytest
|
||||
import os
|
||||
from flask import Flask
|
||||
from api.reward_api import reward_api
|
||||
from db.db import reward_db, child_db
|
||||
from tinydb import Query
|
||||
from models.reward import Reward
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
app = Flask(__name__)
|
||||
app.register_blueprint(reward_api)
|
||||
app.config['TESTING'] = True
|
||||
with app.test_client() as client:
|
||||
yield client
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def cleanup_db():
|
||||
yield
|
||||
reward_db.close()
|
||||
if os.path.exists('rewards.json'):
|
||||
os.remove('rewards.json')
|
||||
|
||||
def test_add_reward(client):
|
||||
response = client.put('/reward/add', json={'name': 'Vacation', 'cost': 10, 'description': "A test reward"})
|
||||
assert response.status_code == 201
|
||||
assert b'Reward Vacation added.' in response.data
|
||||
# verify in database
|
||||
rewards = reward_db.all()
|
||||
assert any(reward.get('name') == 'Vacation' and reward.get('cost') == 10 and reward.get('description') == "A test reward" and reward.get('image_id') == '' for reward in rewards)
|
||||
|
||||
response = client.put('/reward/add', json={'name': 'Ice Cream', 'cost': 4, 'description': "A test ice cream", 'image_id': 'ice_cream'})
|
||||
assert response.status_code == 201
|
||||
assert b'Reward Ice Cream added.' in response.data
|
||||
# verify in database
|
||||
rewards = reward_db.all()
|
||||
assert any(reward.get('name') == 'Ice Cream' and reward.get('cost') == 4 and reward.get('description') == "A test ice cream" and reward.get('image_id') == 'ice_cream' for reward in rewards)
|
||||
|
||||
|
||||
def test_list_rewards(client):
|
||||
reward_db.truncate()
|
||||
reward_db.insert(Reward(name='Reward1', description='Desc1', cost=5).to_dict())
|
||||
reward_db.insert(Reward(name='Reward2', description='Desc2', cost=15, image_id='ice_cream').to_dict())
|
||||
response = client.get('/reward/list')
|
||||
assert response.status_code == 200
|
||||
assert b'rewards' in response.data
|
||||
data = response.json
|
||||
assert len(data['rewards']) == 2
|
||||
|
||||
|
||||
def test_get_reward_not_found(client):
|
||||
response = client.get('/reward/nonexistent-id')
|
||||
assert response.status_code == 404
|
||||
assert b'Reward not found' in response.data
|
||||
|
||||
def test_delete_reward_not_found(client):
|
||||
response = client.delete('/reward/nonexistent-id')
|
||||
assert response.status_code == 404
|
||||
assert b'Reward not found' in response.data
|
||||
|
||||
def test_delete_assigned_reward_removes_from_child(client):
|
||||
# create task and child with the task already assigned
|
||||
reward_db.insert({'id': 'r_delete_assigned', 'name': 'Temp Task', 'cost': 5})
|
||||
child_db.insert({
|
||||
'id': 'child_for_reward_delete',
|
||||
'name': 'Frank',
|
||||
'age': 7,
|
||||
'points': 0,
|
||||
'rewards': ['r_delete_assigned'],
|
||||
'tasks': []
|
||||
})
|
||||
|
||||
ChildQuery = Query()
|
||||
# precondition: child has the task
|
||||
assert 'r_delete_assigned' in child_db.search(ChildQuery.id == 'child_for_reward_delete')[0].get('rewards', [])
|
||||
|
||||
# call the delete endpoint
|
||||
resp = client.delete('/reward/r_delete_assigned')
|
||||
assert resp.status_code == 200
|
||||
|
||||
# verify the task id is no longer in the child's tasks
|
||||
child = child_db.search(ChildQuery.id == 'child_for_reward_delete')[0]
|
||||
assert 'r_delete_assigned' not in child.get('rewards', [])
|
||||
83
tests/test_task_api.py
Normal file
@@ -0,0 +1,83 @@
|
||||
import pytest
|
||||
import os
|
||||
from flask import Flask
|
||||
from api.task_api import task_api
|
||||
from db.db import task_db, child_db
|
||||
from tinydb import Query
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
app = Flask(__name__)
|
||||
app.register_blueprint(task_api)
|
||||
app.config['TESTING'] = True
|
||||
with app.test_client() as client:
|
||||
yield client
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def cleanup_db():
|
||||
yield
|
||||
task_db.close()
|
||||
if os.path.exists('tasks.json'):
|
||||
os.remove('tasks.json')
|
||||
|
||||
def test_add_task(client):
|
||||
response = client.put('/task/add', json={'name': 'Clean Room', 'points': 10, 'is_good': True})
|
||||
assert response.status_code == 201
|
||||
assert b'Task Clean Room added.' in response.data
|
||||
# verify in database
|
||||
tasks = task_db.all()
|
||||
assert any(task.get('name') == 'Clean Room' and task.get('points') == 10 and task.get('is_good') is True and task.get('image_id') == '' for task in tasks)
|
||||
|
||||
response = client.put('/task/add', json={'name': 'Eat Dinner', 'points': 5, 'is_good': False, 'image_id': 'meal'})
|
||||
assert response.status_code == 201
|
||||
assert b'Task Eat Dinner added.' in response.data
|
||||
# verify in database
|
||||
tasks = task_db.all()
|
||||
assert any(task.get('name') == 'Eat Dinner' and task.get('points') == 5 and task.get('is_good') is False and task.get('image_id') == 'meal' for task in tasks)
|
||||
|
||||
|
||||
|
||||
def test_list_tasks(client):
|
||||
task_db.truncate()
|
||||
task_db.insert({'id': 't1', 'name': 'Task1', 'points': 5, 'is_good': True})
|
||||
task_db.insert({'id': 't2', 'name': 'Task2', 'points': 15, 'is_good': False, 'image_id': 'meal'})
|
||||
response = client.get('/task/list')
|
||||
assert response.status_code == 200
|
||||
assert b'tasks' in response.data
|
||||
data = response.json
|
||||
assert len(data['tasks']) == 2
|
||||
|
||||
|
||||
def test_get_task_not_found(client):
|
||||
response = client.get('/task/nonexistent-id')
|
||||
assert response.status_code == 404
|
||||
assert b'Task not found' in response.data
|
||||
|
||||
def test_delete_task_not_found(client):
|
||||
response = client.delete('/task/nonexistent-id')
|
||||
assert response.status_code == 404
|
||||
assert b'Task not found' in response.data
|
||||
|
||||
def test_delete_assigned_task_removes_from_child(client):
|
||||
# create task and child with the task already assigned
|
||||
task_db.insert({'id': 't_delete_assigned', 'name': 'Temp Task', 'points': 5, 'is_good': True})
|
||||
child_db.insert({
|
||||
'id': 'child_for_task_delete',
|
||||
'name': 'Frank',
|
||||
'age': 7,
|
||||
'points': 0,
|
||||
'tasks': ['t_delete_assigned'],
|
||||
'rewards': []
|
||||
})
|
||||
|
||||
ChildQuery = Query()
|
||||
# precondition: child has the task
|
||||
assert 't_delete_assigned' in child_db.search(ChildQuery.id == 'child_for_task_delete')[0].get('tasks', [])
|
||||
|
||||
# call the delete endpoint
|
||||
resp = client.delete('/task/t_delete_assigned')
|
||||
assert resp.status_code == 200
|
||||
|
||||
# verify the task id is no longer in the child's tasks
|
||||
child = child_db.search(ChildQuery.id == 'child_for_task_delete')[0]
|
||||
assert 't_delete_assigned' not in child.get('tasks', [])
|
||||
8
web/vue-app/.editorconfig
Normal file
@@ -0,0 +1,8 @@
|
||||
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
|
||||
charset = utf-8
|
||||
indent_size = 2
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
end_of_line = lf
|
||||
max_line_length = 100
|
||||
1
web/vue-app/.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
||||
* text=auto eol=lf
|
||||
36
web/vue-app/.gitignore
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
coverage
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
*.tsbuildinfo
|
||||
|
||||
.eslintcache
|
||||
|
||||
# Cypress
|
||||
/cypress/videos/
|
||||
/cypress/screenshots/
|
||||
|
||||
# Vitest
|
||||
__screenshots__/
|
||||
6
web/vue-app/.prettierrc.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/prettierrc",
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"printWidth": 100
|
||||
}
|
||||
28
web/vue-app/192.168.1.102+1-key.pem
Normal file
@@ -0,0 +1,28 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCu4wdLkE3sTDd9
|
||||
Mea+AJFpnsk2nR0CBm0MqopJvr0E7X+iOpVRdfHOqxQ5vS6a5sjMjDDLjpO6Qb6Z
|
||||
o0zrm6N+djhfPlITdwXXoNW4TURrWaBFyQXDzHt+eaVBMXpRhLYFcpHvFam+EfM7
|
||||
5XB5UmNe400eKhAC5IMIOxuAWhMMN1TfMGVkt+pQAhTFYbeWMvEfCxY0IY3wfzMf
|
||||
7+ZMKc1YQ0R8hhwqF2KP5BswwTFAIANhopkqGkszOz5ZRQIVlZCSYehbfGEklpPo
|
||||
LjPeF0qZJWwPzZy0fT9SJH1sLl8SA/+KTo6n3GeJpPdG+MlxMgi7a9hDXM8MlwCV
|
||||
us34apVbAgMBAAECggEAWfQCM5a0zd7iB64cHgySvr3ihwnG+tytSH+Lg6Ts+lTi
|
||||
emIhnXXJ+2A5lf09tIUSMUvGaV0blQjt7X52ORWjwY8zLaITe1mUErXyV8q+b2z5
|
||||
KAvewDg0KPiOzHqTjMxzB1hHwa7l0RLQhjVcZbq/y/WkG+jMtYAt+ZTVb25lL7mI
|
||||
JAkBTmATcPrsnYxqk1kwZsTcgrnXZwXKWBKtKy6i728V1bLxYF/KyizGnaClt0+I
|
||||
H0qi5jnF5ispXmdBlJa2kFOzDNRa4iO7m328nqFSikdYT/xaPfGwNtTplgSxQ2kE
|
||||
WkbKq6Tjx1K4XR4en5sT7NXst89Vl53UwWT85AZxuQKBgQDeyAOu1H81/SDhtvOt
|
||||
mdbb6WvRFrzHSRQEX5vBasD1cgKz3uTQeAUKlTGcKZz796pDnluNoAOBx9bnXeYp
|
||||
6PMfif5tJmykmBPik9CtKlsZEwGexjrfvsGM+tISCRDMfF1FAk16BBlQePUWUyEi
|
||||
kqdw8pk8HGkA82Blhu3N8dJbFQKBgQDI9sp99Za7cHUHGKjF8wFS7feFwvbeYzY5
|
||||
WLXcCTac75Xoqr9O59a74Fz2QdBZGT5eCg06NUbxJ2YTJuWbfRR0jHYEMPUY9iTj
|
||||
cYCiYYS67NMjHe4/wZU430S/Egz4FUXyf/XswhURjM5pHxyWmLh7XejjRKs8x6GE
|
||||
WUva37GKrwKBgBj0VJ1HxjwY7474/FCs08lsWxxfrKOyBuD6iKrgt16G99CIHh9P
|
||||
4liuH5F7g88hjdvnKCA0FVB7PxJJjVeSdXFJ9srpK/A/7LJLlmtfPDcRzvOnBr87
|
||||
Udjl25QTmeMd5yCswlrxjJhcBDAM/cAupzzan9mA4S4vFNQqigawmLyFAoGACreW
|
||||
jucU9cQGia1X+s59yJVmONzv22ZBEwfXEvfu0Km6PeE1OJkGi5hofL1/xfChsdQp
|
||||
ZmxG7z9hoy3U2tjtyVVgSdLujzk5OGPqLz6yHGHa1KmY9g91zMWjXekxhd1kkI0g
|
||||
aVLkWr4+l76QALv+Qp38eHpGA4TF6U/1yqNZTYMCgYB12cwRZkihD60MfXaq/vqY
|
||||
zu2AC49k3h+QKKtYMJ7i5i1nwH9EyrycBLtE9xFm40GYrqUJZ/55BwjaskMI8x5h
|
||||
X7r4lK1XqOGRIX7nwSWS1UOucj2CAqAYrXal8FzOo+aazBcRCcUiC0vt49ICVKss
|
||||
+wapUQ7517R2w2tUnAOidg==
|
||||
-----END PRIVATE KEY-----
|
||||
25
web/vue-app/192.168.1.102+1.pem
Normal file
@@ -0,0 +1,25 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIETDCCArSgAwIBAgIQDRI/2ndt0jHqQgdHokXM3jANBgkqhkiG9w0BAQsFADCB
|
||||
hTEeMBwGA1UEChMVbWtjZXJ0IGRldmVsb3BtZW50IENBMS0wKwYDVQQLDCRERVNL
|
||||
VE9QLUFVOTBHNkpcUnlhbkBERVNLVE9QLUFVOTBHNkoxNDAyBgNVBAMMK21rY2Vy
|
||||
dCBERVNLVE9QLUFVOTBHNkpcUnlhbkBERVNLVE9QLUFVOTBHNkowHhcNMjUxMTE4
|
||||
MTU1MjA1WhcNMjgwMjE4MTU1MjA1WjBYMScwJQYDVQQKEx5ta2NlcnQgZGV2ZWxv
|
||||
cG1lbnQgY2VydGlmaWNhdGUxLTArBgNVBAsMJERFU0tUT1AtQVU5MEc2SlxSeWFu
|
||||
QERFU0tUT1AtQVU5MEc2SjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB
|
||||
AK7jB0uQTexMN30x5r4AkWmeyTadHQIGbQyqikm+vQTtf6I6lVF18c6rFDm9Lprm
|
||||
yMyMMMuOk7pBvpmjTOubo352OF8+UhN3Bdeg1bhNRGtZoEXJBcPMe355pUExelGE
|
||||
tgVyke8Vqb4R8zvlcHlSY17jTR4qEALkgwg7G4BaEww3VN8wZWS36lACFMVht5Yy
|
||||
8R8LFjQhjfB/Mx/v5kwpzVhDRHyGHCoXYo/kGzDBMUAgA2GimSoaSzM7PllFAhWV
|
||||
kJJh6Ft8YSSWk+guM94XSpklbA/NnLR9P1IkfWwuXxID/4pOjqfcZ4mk90b4yXEy
|
||||
CLtr2ENczwyXAJW6zfhqlVsCAwEAAaNkMGIwDgYDVR0PAQH/BAQDAgWgMBMGA1Ud
|
||||
JQQMMAoGCCsGAQUFBwMBMB8GA1UdIwQYMBaAFMdcsDJbHnPC2tztDztuvlALzXcJ
|
||||
MBoGA1UdEQQTMBGCCWxvY2FsaG9zdIcEwKgBZjANBgkqhkiG9w0BAQsFAAOCAYEA
|
||||
nhDUKSW8Ti6h39cth+JeX3isU/cBO1l3y3Ptflb3i02na3x2b39hVstm1xCuxF43
|
||||
KNLF+Mfe1OegifDHCxkEQGM6HvppBjbUchZlTcr0q1xusU6v/moGxYnEP1BewvRv
|
||||
B5eNHSADROdRNSbtuK8DeVPWG3uXzyqmQPTlSmtNYAXuigsmPEodiFLBxIoE1qWd
|
||||
Y4EGWK9b7Vka7IjIH6KnegJe5n9aI102Hu2rZwP8WIlDhqPzpDprrpj0n8t38Q5z
|
||||
FK+Qae3E1i5vSAZoX0buUngnpXAbGDQl/Sq/PIIFeljlOWtI8JDhUb2nZHWnPoyA
|
||||
NuiGgYsOFfU6eH6gFw/sTphjrABIQ9JbQoSjeUA3vcol1De7mqCPJ6oC4lbeMdr1
|
||||
xt1bD6UiIhjx219ikAlnNSfm9eZsqBepPLi029wBDP/De1RdGRcLRwtNBxNWkYcc
|
||||
t/7BWjjDd0/67meIEK85kYatfK1uUrVOjeU097LjwHjvsfsqKZr3Kok+kAzFalvF
|
||||
-----END CERTIFICATE-----
|
||||
54
web/vue-app/README.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# vue-app
|
||||
|
||||
This template should help get you started developing with Vue 3 in Vite.
|
||||
|
||||
## Recommended IDE Setup
|
||||
|
||||
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
|
||||
|
||||
## Recommended Browser Setup
|
||||
|
||||
- Chromium-based browsers (Chrome, Edge, Brave, etc.):
|
||||
- [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd)
|
||||
- [Turn on Custom Object Formatter in Chrome DevTools](http://bit.ly/object-formatters)
|
||||
- Firefox:
|
||||
- [Vue.js devtools](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/)
|
||||
- [Turn on Custom Object Formatter in Firefox DevTools](https://fxdx.dev/firefox-devtools-custom-object-formatters/)
|
||||
|
||||
## Type Support for `.vue` Imports in TS
|
||||
|
||||
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
|
||||
|
||||
## Customize configuration
|
||||
|
||||
See [Vite Configuration Reference](https://vite.dev/config/).
|
||||
|
||||
## Project Setup
|
||||
|
||||
```sh
|
||||
npm install
|
||||
```
|
||||
|
||||
### Compile and Hot-Reload for Development
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Type-Check, Compile and Minify for Production
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Run Unit Tests with [Vitest](https://vitest.dev/)
|
||||
|
||||
```sh
|
||||
npm run test:unit
|
||||
```
|
||||
|
||||
### Lint with [ESLint](https://eslint.org/)
|
||||
|
||||
```sh
|
||||
npm run lint
|
||||
```
|
||||
1
web/vue-app/env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
28
web/vue-app/eslint.config.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { globalIgnores } from 'eslint/config'
|
||||
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
|
||||
import pluginVue from 'eslint-plugin-vue'
|
||||
import pluginVitest from '@vitest/eslint-plugin'
|
||||
import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'
|
||||
|
||||
// To allow more languages other than `ts` in `.vue` files, uncomment the following lines:
|
||||
// import { configureVueProject } from '@vue/eslint-config-typescript'
|
||||
// configureVueProject({ scriptLangs: ['ts', 'tsx'] })
|
||||
// More info at https://github.com/vuejs/eslint-config-typescript/#advanced-setup
|
||||
|
||||
export default defineConfigWithVueTs(
|
||||
{
|
||||
name: 'app/files-to-lint',
|
||||
files: ['**/*.{ts,mts,tsx,vue}'],
|
||||
},
|
||||
|
||||
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
|
||||
|
||||
pluginVue.configs['flat/essential'],
|
||||
vueTsConfigs.recommended,
|
||||
|
||||
{
|
||||
...pluginVitest.configs.recommended,
|
||||
files: ['src/**/__tests__/*'],
|
||||
},
|
||||
skipFormatting,
|
||||
)
|
||||
16
web/vue-app/index.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
|
||||
/>
|
||||
<title>Chore Time</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
6516
web/vue-app/package-lock.json
generated
Normal file
45
web/vue-app/package.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "vue-app",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "run-p type-check \"build-only {@}\" --",
|
||||
"preview": "vite preview",
|
||||
"test:unit": "vitest",
|
||||
"build-only": "vite build",
|
||||
"type-check": "vue-tsc --build",
|
||||
"lint": "eslint . --fix --cache",
|
||||
"format": "prettier --write src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.5.22",
|
||||
"vue-router": "^4.6.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/node22": "^22.0.2",
|
||||
"@types/jsdom": "^27.0.0",
|
||||
"@types/node": "^22.18.11",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@vitest/eslint-plugin": "^1.3.23",
|
||||
"@vue/eslint-config-prettier": "^10.2.0",
|
||||
"@vue/eslint-config-typescript": "^14.6.0",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"@vue/tsconfig": "^0.8.1",
|
||||
"eslint": "^9.37.0",
|
||||
"eslint-plugin-vue": "~10.5.0",
|
||||
"jiti": "^2.6.1",
|
||||
"jsdom": "^27.0.1",
|
||||
"npm-run-all2": "^8.0.4",
|
||||
"prettier": "3.6.2",
|
||||
"typescript": "~5.9.0",
|
||||
"vite": "^7.1.11",
|
||||
"vite-plugin-vue-devtools": "^8.0.3",
|
||||
"vitest": "^3.2.4",
|
||||
"vue-tsc": "^3.1.1"
|
||||
}
|
||||
}
|
||||
BIN
web/vue-app/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
12
web/vue-app/src/App.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts"></script>
|
||||
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
11
web/vue-app/src/__tests__/App.spec.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
import { mount } from '@vue/test-utils'
|
||||
import App from '../App.vue'
|
||||
|
||||
describe('App', () => {
|
||||
it('mounts renders properly', () => {
|
||||
const wrapper = mount(App)
|
||||
expect(wrapper.text()).toContain('You did it!')
|
||||
})
|
||||
})
|
||||
64
web/vue-app/src/common/imageCache.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
export const DEFAULT_IMAGE_CACHE = 'images-v1'
|
||||
|
||||
const objectUrlMap = new Map<string, string>()
|
||||
|
||||
export async function getCachedImageUrl(
|
||||
imageId: string,
|
||||
cacheName = DEFAULT_IMAGE_CACHE,
|
||||
): Promise<string> {
|
||||
if (!imageId) throw new Error('imageId required')
|
||||
|
||||
// reuse existing object URL if created in this session
|
||||
const existing = objectUrlMap.get(imageId)
|
||||
if (existing) return existing
|
||||
|
||||
const requestUrl = `/api/image/request/${imageId}`
|
||||
|
||||
// Try Cache Storage first
|
||||
let response: Response | undefined
|
||||
if ('caches' in window) {
|
||||
const cache = await caches.open(cacheName)
|
||||
response = await cache.match(requestUrl)
|
||||
if (!response) {
|
||||
const fetched = await fetch(requestUrl)
|
||||
if (!fetched.ok) throw new Error(`HTTP ${fetched.status}`)
|
||||
// store a clone in Cache Storage (non-blocking)
|
||||
cache.put(requestUrl, fetched.clone()).catch((e) => {
|
||||
console.warn('Cache put failed:', e)
|
||||
})
|
||||
response = fetched
|
||||
}
|
||||
} else {
|
||||
const fetched = await fetch(requestUrl)
|
||||
if (!fetched.ok) throw new Error(`HTTP ${fetched.status}`)
|
||||
response = fetched
|
||||
}
|
||||
|
||||
const blob = await response.blob()
|
||||
const objectUrl = URL.createObjectURL(blob)
|
||||
objectUrlMap.set(imageId, objectUrl)
|
||||
return objectUrl
|
||||
}
|
||||
|
||||
export function revokeImageUrl(imageId: string) {
|
||||
const url = objectUrlMap.get(imageId)
|
||||
if (url) {
|
||||
try {
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
objectUrlMap.delete(imageId)
|
||||
}
|
||||
}
|
||||
|
||||
export function revokeAllImageUrls() {
|
||||
for (const url of objectUrlMap.values()) {
|
||||
try {
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
objectUrlMap.clear()
|
||||
}
|
||||
38
web/vue-app/src/components/AssignTaskButton.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import AssignTasksDialog from './AssignTasksDialog.vue'
|
||||
|
||||
const props = defineProps<{ childId: string | number | null }>()
|
||||
const showDialog = ref(false)
|
||||
const openDialog = () => {
|
||||
showDialog.value = true
|
||||
}
|
||||
const closeDialog = () => {
|
||||
showDialog.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<button class="assign-tasks-btn" @click="openDialog">Assign Task</button>
|
||||
<AssignTasksDialog v-if="showDialog" :child-id="props.childId" @close="closeDialog" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.assign-tasks-btn {
|
||||
background: #667eea;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 0.7rem 1.4rem;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
margin-top: 2rem;
|
||||
transition: background 0.18s;
|
||||
}
|
||||
.assign-tasks-btn:hover {
|
||||
background: #5a67d8;
|
||||
}
|
||||
</style>
|
||||
170
web/vue-app/src/components/AssignTasksDialog.vue
Normal file
@@ -0,0 +1,170 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { getCachedImageUrl } from '../common/imageCache'
|
||||
|
||||
const props = defineProps<{ childId: string | number }>()
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
interface Task {
|
||||
id: string
|
||||
name: string
|
||||
is_good: boolean
|
||||
points: number
|
||||
image_id?: string | null
|
||||
image_url?: string | null
|
||||
}
|
||||
|
||||
const tasks = ref<Task[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const fetchTasks = async () => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const resp = await fetch(`/api/child/${props.childId}/list-assignable-tasks`)
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
const data = await resp.json()
|
||||
const taskList: Task[] = data.assignable_tasks || []
|
||||
|
||||
// Fetch images for each task if image_id is present
|
||||
await Promise.all(
|
||||
taskList.map(async (task) => {
|
||||
if (task.image_id) {
|
||||
try {
|
||||
task.image_url = await getCachedImageUrl(task.image_id)
|
||||
} catch (e) {
|
||||
task.image_url = null
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
tasks.value = taskList
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to fetch tasks'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchTasks)
|
||||
watch(() => props.childId, fetchTasks)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="modal-backdrop">
|
||||
<div class="modal">
|
||||
<h3>Assign Tasks</h3>
|
||||
<div v-if="loading" class="loading">Loading tasks...</div>
|
||||
<div v-else-if="error" class="error">{{ error }}</div>
|
||||
<div v-else class="task-listbox">
|
||||
<div
|
||||
v-for="task in tasks"
|
||||
:key="task.id"
|
||||
class="task-list-item"
|
||||
:class="{ good: task.is_good, bad: !task.is_good }"
|
||||
>
|
||||
<img v-if="task.image_url" :src="task.image_url" alt="Task" class="task-image" />
|
||||
<span class="task-name">{{ task.name }}</span>
|
||||
<span class="task-points">
|
||||
{{ task.is_good ? task.points : '-' + task.points }} pts
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="close-btn" @click="emit('close')">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1200;
|
||||
}
|
||||
.modal {
|
||||
background: #fff;
|
||||
color: #222;
|
||||
padding: 1.5rem 2rem;
|
||||
border-radius: 12px;
|
||||
min-width: 260px;
|
||||
max-width: 95vw;
|
||||
max-height: 90vh;
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.2);
|
||||
text-align: center;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.task-listbox {
|
||||
max-height: 320px;
|
||||
min-width: 220px;
|
||||
overflow-y: auto;
|
||||
margin: 1.2rem 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
.task-list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: 2px solid #38c172;
|
||||
border-radius: 8px;
|
||||
padding: 0.6rem 1rem;
|
||||
background: #f8fafc;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 500;
|
||||
transition: border 0.18s;
|
||||
}
|
||||
.task-list-item.bad {
|
||||
border-color: #e53e3e;
|
||||
background: #fff5f5;
|
||||
}
|
||||
.task-list-item.good {
|
||||
border-color: #38c172;
|
||||
background: #f0fff4;
|
||||
}
|
||||
.task-name {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
}
|
||||
.task-points {
|
||||
min-width: 60px;
|
||||
text-align: right;
|
||||
font-weight: 600;
|
||||
}
|
||||
.close-btn {
|
||||
margin-top: 1.2rem;
|
||||
background: #f3f3f3;
|
||||
color: #666;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 0.5rem 1.1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.close-btn:hover {
|
||||
background: #e2e8f0;
|
||||
}
|
||||
.loading,
|
||||
.error {
|
||||
margin: 1.2rem 0;
|
||||
color: #888;
|
||||
}
|
||||
.error {
|
||||
color: #e53e3e;
|
||||
}
|
||||
.task-image {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
margin-right: 0.7rem;
|
||||
background: #eee;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
123
web/vue-app/src/components/ChildDetailCard.vue
Normal file
@@ -0,0 +1,123 @@
|
||||
<script setup lang="ts">
|
||||
import { defineProps, toRefs, ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { getCachedImageUrl, revokeAllImageUrls } from '../common/imageCache'
|
||||
|
||||
interface Child {
|
||||
id: string | number
|
||||
name: string
|
||||
age: number
|
||||
points?: number
|
||||
image_id: string | null
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
child: Child | null
|
||||
}>()
|
||||
|
||||
const { child } = toRefs(props)
|
||||
const imageUrl = ref<string | null>(null)
|
||||
|
||||
const imageCacheName = 'images-v1'
|
||||
|
||||
const fetchImage = async (imageId: string) => {
|
||||
try {
|
||||
const url = await getCachedImageUrl(imageId, imageCacheName)
|
||||
imageUrl.value = url
|
||||
} catch (err) {
|
||||
console.error('Error fetching child image:', err)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (child.value && child.value.image_id) {
|
||||
fetchImage(child.value.image_id)
|
||||
}
|
||||
})
|
||||
|
||||
// Revoke created object URLs when component unmounts to avoid memory leaks
|
||||
onBeforeUnmount(() => {
|
||||
revokeAllImageUrls()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="child" class="detail-card">
|
||||
<h1>{{ child.name }}</h1>
|
||||
<img v-if="imageUrl" :src="imageUrl" alt="Child Image" class="child-image" />
|
||||
<div class="info">
|
||||
<div class="info-item">
|
||||
<span class="label">Age:</span>
|
||||
<span class="value">{{ child.age }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">Points:</span>
|
||||
<span class="value">{{ child.points ?? '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.detail-card {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.18);
|
||||
padding: 1.2rem 1rem;
|
||||
max-width: 420px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.detail-card h1 {
|
||||
font-size: 1.3rem;
|
||||
color: #333;
|
||||
margin-bottom: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.child-image {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
object-fit: cover;
|
||||
border-radius: 50%;
|
||||
display: block;
|
||||
margin: 0 auto 0.7rem auto;
|
||||
}
|
||||
|
||||
.info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0.7rem;
|
||||
background: #f5f5f5;
|
||||
border-radius: 7px;
|
||||
font-size: 0.98rem;
|
||||
}
|
||||
|
||||
/* Even more compact on small screens */
|
||||
@media (max-width: 480px) {
|
||||
.detail-card {
|
||||
padding: 0.7rem 0.4rem;
|
||||
max-width: 98vw;
|
||||
}
|
||||
.detail-card h1 {
|
||||
font-size: 1.05rem;
|
||||
margin-bottom: 0.7rem;
|
||||
}
|
||||
.child-image {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.info-item {
|
||||
padding: 0.38rem 0.5rem;
|
||||
font-size: 0.93rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
687
web/vue-app/src/components/ChildForm.vue
Normal file
@@ -0,0 +1,687 @@
|
||||
<script setup lang="ts">
|
||||
import { defineProps, defineEmits, ref, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { getCachedImageUrl } from '../common/imageCache'
|
||||
|
||||
const props = defineProps<{
|
||||
child: { id: string | number; name: string; age: number; image_id?: string | null } | null
|
||||
}>()
|
||||
const emit = defineEmits(['close', 'updated'])
|
||||
|
||||
const name = ref(props.child?.name ?? '')
|
||||
const age = ref(props.child?.age ?? '')
|
||||
const image = ref<File | null>(null)
|
||||
|
||||
// For image selection
|
||||
const availableImageIds = ref<string[]>([])
|
||||
const availableImageUrls = ref<{ id: string; url: string }[]>([])
|
||||
const loadingImages = ref(false)
|
||||
const selectedImageId = ref<string | null>(props.child?.image_id ?? null)
|
||||
const localImageUrl = ref<string | null>(null)
|
||||
|
||||
// Camera variables
|
||||
const showCamera = ref(false)
|
||||
const cameraStream = ref<MediaStream | null>(null)
|
||||
const cameraVideo = ref<HTMLVideoElement | null>(null)
|
||||
const cameraError = ref<string | null>(null)
|
||||
const capturedImageUrl = ref<string | null>(null)
|
||||
const cameraFile = ref<File | null>(null)
|
||||
|
||||
watch(
|
||||
() => props.child,
|
||||
(c) => {
|
||||
name.value = c?.name ?? ''
|
||||
age.value = c?.age ?? ''
|
||||
image.value = null
|
||||
selectedImageId.value = c?.image_id ?? null
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const selectImage = (id: string) => {
|
||||
selectedImageId.value = id
|
||||
}
|
||||
|
||||
const submit = async () => {
|
||||
let imageId = selectedImageId.value
|
||||
|
||||
// If the selected image is a local upload, upload it first
|
||||
if (imageId === 'local-upload') {
|
||||
let file: File | null = null
|
||||
|
||||
// Try to get the file from the file input
|
||||
if (fileInput.value && fileInput.value.files && fileInput.value.files.length > 0) {
|
||||
file = fileInput.value.files[0]
|
||||
} else if (cameraFile.value) {
|
||||
file = cameraFile.value
|
||||
}
|
||||
|
||||
if (file) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('type', '1')
|
||||
formData.append('permanent', 'false')
|
||||
try {
|
||||
const resp = await fetch('/api/image/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
if (!resp.ok) throw new Error('Image upload failed')
|
||||
const data = await resp.json()
|
||||
imageId = data.id
|
||||
} catch (err) {
|
||||
alert('Failed to upload image.')
|
||||
return
|
||||
}
|
||||
} else {
|
||||
alert('No image file found to upload.')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Now update the child
|
||||
try {
|
||||
const resp = await fetch(`/api/child/${props.child?.id}/edit`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: name.value,
|
||||
age: age.value,
|
||||
image_id: imageId,
|
||||
}),
|
||||
})
|
||||
if (!resp.ok) throw new Error('Failed to update child')
|
||||
emit('updated')
|
||||
} catch (err) {
|
||||
alert('Failed to update child.')
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch available images on mount
|
||||
onMounted(async () => {
|
||||
loadingImages.value = true
|
||||
try {
|
||||
const resp = await fetch('/api/image/list?type=1')
|
||||
if (resp.ok) {
|
||||
const data = await resp.json()
|
||||
let ids = data.ids || []
|
||||
if (props.child?.image_id && ids.includes(props.child.image_id)) {
|
||||
ids = [props.child.image_id, ...ids.filter((id) => id !== props.child.image_id)]
|
||||
} else {
|
||||
// No current image, just use the list as-is
|
||||
ids = [...ids]
|
||||
}
|
||||
availableImageIds.value = ids
|
||||
// Fetch URLs for each image id
|
||||
const urls = await Promise.all(
|
||||
availableImageIds.value.map(async (id: string) => {
|
||||
try {
|
||||
const url = await getCachedImageUrl(id)
|
||||
return { id, url }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}),
|
||||
)
|
||||
availableImageUrls.value = urls.filter(Boolean) as { id: string; url: string }[]
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load available images', err)
|
||||
} finally {
|
||||
loadingImages.value = false
|
||||
}
|
||||
})
|
||||
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const addFromLocal = () => {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
const onFileChange = (event: Event) => {
|
||||
const files = (event.target as HTMLInputElement).files
|
||||
if (files && files.length > 0) {
|
||||
const file = files[0]
|
||||
// Clean up previous local object URL if any
|
||||
if (localImageUrl.value) {
|
||||
URL.revokeObjectURL(localImageUrl.value)
|
||||
}
|
||||
const url = URL.createObjectURL(file)
|
||||
localImageUrl.value = url
|
||||
|
||||
// Insert at the front of the image lists
|
||||
availableImageUrls.value = [
|
||||
{ id: 'local-upload', url },
|
||||
...availableImageUrls.value.filter((img) => img.id !== 'local-upload'),
|
||||
]
|
||||
availableImageIds.value = [
|
||||
'local-upload',
|
||||
...availableImageIds.value.filter((id) => id !== 'local-upload'),
|
||||
]
|
||||
selectedImageId.value = 'local-upload'
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up local object URL on unmount
|
||||
onBeforeUnmount(() => {
|
||||
if (localImageUrl.value) {
|
||||
URL.revokeObjectURL(localImageUrl.value)
|
||||
}
|
||||
})
|
||||
|
||||
// Open camera modal
|
||||
const addFromCamera = async () => {
|
||||
cameraError.value = null
|
||||
capturedImageUrl.value = null
|
||||
showCamera.value = true
|
||||
await nextTick()
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ video: true })
|
||||
cameraStream.value = stream
|
||||
if (cameraVideo.value) {
|
||||
cameraVideo.value.srcObject = stream
|
||||
await cameraVideo.value.play()
|
||||
}
|
||||
} catch (err) {
|
||||
cameraError.value = 'Unable to access camera'
|
||||
cameraStream.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// Take photo
|
||||
const takePhoto = async () => {
|
||||
if (!cameraVideo.value) return
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = cameraVideo.value.videoWidth
|
||||
canvas.height = cameraVideo.value.videoHeight
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (ctx) {
|
||||
ctx.drawImage(cameraVideo.value, 0, 0, canvas.width, canvas.height)
|
||||
const dataUrl = canvas.toDataURL('image/png')
|
||||
capturedImageUrl.value = dataUrl
|
||||
}
|
||||
}
|
||||
|
||||
// Confirm photo
|
||||
const confirmPhoto = async () => {
|
||||
if (capturedImageUrl.value) {
|
||||
// Clean up previous local object URL if any
|
||||
if (localImageUrl.value) {
|
||||
URL.revokeObjectURL(localImageUrl.value)
|
||||
}
|
||||
|
||||
// Create an image element to load the captured data URL
|
||||
const img = new window.Image()
|
||||
img.src = capturedImageUrl.value
|
||||
|
||||
await new Promise((resolve) => {
|
||||
img.onload = resolve
|
||||
})
|
||||
|
||||
// Calculate new dimensions
|
||||
let { width, height } = img
|
||||
const maxDim = 512
|
||||
if (width > maxDim || height > maxDim) {
|
||||
if (width > height) {
|
||||
height = Math.round((height * maxDim) / width)
|
||||
width = maxDim
|
||||
} else {
|
||||
width = Math.round((width * maxDim) / height)
|
||||
height = maxDim
|
||||
}
|
||||
}
|
||||
|
||||
// Draw to canvas
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
const ctx = canvas.getContext('2d')
|
||||
ctx?.drawImage(img, 0, 0, width, height)
|
||||
|
||||
// Convert canvas to blob and object URL
|
||||
const blob: Blob = await new Promise((resolve) =>
|
||||
canvas.toBlob((b) => resolve(b!), 'image/png'),
|
||||
)
|
||||
const url = URL.createObjectURL(blob)
|
||||
localImageUrl.value = url
|
||||
|
||||
// Store the File for upload
|
||||
cameraFile.value = new File([blob], 'camera.png', { type: 'image/png' })
|
||||
|
||||
availableImageUrls.value = [
|
||||
{ id: 'local-upload', url },
|
||||
...availableImageUrls.value.filter((img) => img.id !== 'local-upload'),
|
||||
]
|
||||
availableImageIds.value = [
|
||||
'local-upload',
|
||||
...availableImageIds.value.filter((id) => id !== 'local-upload'),
|
||||
]
|
||||
selectedImageId.value = 'local-upload'
|
||||
}
|
||||
closeCamera()
|
||||
}
|
||||
|
||||
// Retake photo
|
||||
const retakePhoto = async () => {
|
||||
capturedImageUrl.value = null
|
||||
cameraFile.value = null
|
||||
await resumeCameraStream()
|
||||
}
|
||||
|
||||
// Close camera and stop stream
|
||||
const closeCamera = () => {
|
||||
showCamera.value = false
|
||||
capturedImageUrl.value = null
|
||||
if (cameraStream.value) {
|
||||
cameraStream.value.getTracks().forEach((track) => track.stop())
|
||||
cameraStream.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const resumeCameraStream = async () => {
|
||||
await nextTick()
|
||||
if (cameraVideo.value && cameraStream.value) {
|
||||
cameraVideo.value.srcObject = cameraStream.value
|
||||
try {
|
||||
await cameraVideo.value.play()
|
||||
} catch (e) {
|
||||
// ignore play errors
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="modal-backdrop">
|
||||
<div class="modal">
|
||||
<h3>Edit Child</h3>
|
||||
<form @submit.prevent="submit" class="form">
|
||||
<div class="form-group">
|
||||
<label for="child-name">Name</label>
|
||||
<input id="child-name" v-model="name" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="child-age">Age</label>
|
||||
<input id="child-age" v-model="age" type="number" min="0" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Image</label>
|
||||
</div>
|
||||
<div class="image-scroll">
|
||||
<div v-if="loadingImages" class="loading-images">Loading images...</div>
|
||||
<div v-else class="image-list">
|
||||
<img
|
||||
v-for="img in availableImageUrls"
|
||||
:key="img.id"
|
||||
:src="img.url"
|
||||
class="selectable-image"
|
||||
:class="{ selected: selectedImageId === img.id }"
|
||||
:alt="`Image ${img.id}`"
|
||||
@click="selectImage(img.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Hidden file input for local image selection -->
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept=".png,.jpg,.jpeg,.gif,image/png,image/jpeg,image/gif"
|
||||
style="display: none"
|
||||
@change="onFileChange"
|
||||
/>
|
||||
<div class="image-actions">
|
||||
<button type="button" class="icon-btn" @click="addFromLocal" aria-label="Add from device">
|
||||
<span class="icon">+</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
@click="addFromCamera"
|
||||
aria-label="Add from camera"
|
||||
>
|
||||
<span class="icon">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<rect
|
||||
x="3"
|
||||
y="6"
|
||||
width="14"
|
||||
height="10"
|
||||
rx="2"
|
||||
stroke="#667eea"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
<circle cx="10" cy="11" r="3" stroke="#667eea" stroke-width="1.5" />
|
||||
<rect x="7" y="3" width="6" height="3" rx="1" stroke="#667eea" stroke-width="1.5" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="button" class="btn cancel" @click="emit('close')">Cancel</button>
|
||||
<button type="submit" class="btn save">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Camera modal -->
|
||||
<div v-if="showCamera" class="modal-backdrop">
|
||||
<div class="modal camera-modal">
|
||||
<h3>Take a Photo</h3>
|
||||
<div v-if="cameraError" class="camera-error">{{ cameraError }}</div>
|
||||
<div v-else>
|
||||
<div v-if="!capturedImageUrl">
|
||||
<video ref="cameraVideo" autoplay playsinline class="camera-video"></video>
|
||||
<div class="actions">
|
||||
<button type="button" class="btn save" @click="takePhoto">Take Photo</button>
|
||||
<button type="button" class="btn cancel" @click="closeCamera">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<img :src="capturedImageUrl" class="captured-preview" alt="Preview" />
|
||||
<div class="actions">
|
||||
<button type="button" class="btn save" @click="confirmPhoto">Use Photo</button>
|
||||
<button type="button" class="btn cancel" @click="retakePhoto">Retake</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
z-index: 1200;
|
||||
overflow-y: auto;
|
||||
padding-top: max(3vh, env(safe-area-inset-top, 24px));
|
||||
padding-bottom: max(3vh, env(safe-area-inset-bottom, 24px));
|
||||
}
|
||||
|
||||
.modal,
|
||||
.camera-modal {
|
||||
background: #fff;
|
||||
color: #222;
|
||||
padding: 1.2rem 2rem 1.2rem 2rem;
|
||||
border-radius: 12px;
|
||||
width: 360px;
|
||||
max-width: 100vw;
|
||||
max-height: 100vh;
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.2);
|
||||
text-align: center;
|
||||
overflow-y: auto;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* For small screens (portrait or landscape), use 90vw and reduce height */
|
||||
@media (max-width: 600px), (max-height: 480px) {
|
||||
.modal,
|
||||
.camera-modal {
|
||||
width: 90vw;
|
||||
max-width: 98vw;
|
||||
max-height: 94vh;
|
||||
padding: 0.7rem 0.7rem 0.7rem 0.7rem;
|
||||
font-size: 0.97rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* For landscape on larger screens, use 75vw but max 600px */
|
||||
@media (orientation: landscape) and (min-width: 601px) {
|
||||
.modal,
|
||||
.camera-modal {
|
||||
width: 75vw;
|
||||
max-width: 600px;
|
||||
max-height: 94vh;
|
||||
}
|
||||
}
|
||||
|
||||
/* Limit video and preview image height for all screens */
|
||||
.camera-video,
|
||||
.captured-preview {
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
max-height: 180px;
|
||||
border-radius: 12px;
|
||||
background: #222;
|
||||
margin-bottom: 1rem;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin-bottom: 1.2rem;
|
||||
font-size: 1.15rem;
|
||||
color: #667eea;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.1rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.35rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
label {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
input[type='text'],
|
||||
input[type='number'] {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
padding: 0.5rem 0.7rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e6e6e6;
|
||||
font-size: 1rem;
|
||||
background: #fafbff;
|
||||
color: #222;
|
||||
transition: border 0.2s;
|
||||
}
|
||||
input:focus {
|
||||
outline: none;
|
||||
border: 1.5px solid #667eea;
|
||||
}
|
||||
|
||||
.browse-btn {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 0.45rem 1.1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
transition: background 0.18s;
|
||||
}
|
||||
.browse-btn:hover {
|
||||
background: #5a67d8;
|
||||
}
|
||||
|
||||
.image-scroll {
|
||||
width: 100%;
|
||||
margin: 0.7rem 0 0.2rem 0;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
padding-bottom: 0.2rem;
|
||||
}
|
||||
.image-list {
|
||||
display: flex;
|
||||
gap: 0.7rem;
|
||||
min-width: min-content;
|
||||
align-items: center;
|
||||
}
|
||||
.selectable-image {
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
border: 2px solid #e6e6e6;
|
||||
background: #fafbff;
|
||||
cursor: pointer;
|
||||
transition: border 0.18s;
|
||||
}
|
||||
.selectable-image:hover {
|
||||
border-color: #667eea;
|
||||
}
|
||||
.selectable-image.selected {
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 2px #667eea55;
|
||||
}
|
||||
.loading-images {
|
||||
color: #888;
|
||||
font-size: 0.98rem;
|
||||
padding: 0.5rem 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.image-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
background: #f3f3f3;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: background 0.18s;
|
||||
font-size: 1.5rem;
|
||||
color: #667eea;
|
||||
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.07);
|
||||
}
|
||||
.icon-btn:hover {
|
||||
background: #e0e7ff;
|
||||
}
|
||||
.icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.7rem;
|
||||
justify-content: center;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.btn {
|
||||
padding: 0.5rem 1.1rem;
|
||||
border-radius: 8px;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
transition: background 0.18s;
|
||||
}
|
||||
.btn.cancel {
|
||||
background: #f3f3f3;
|
||||
color: #666;
|
||||
}
|
||||
.btn.save {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
}
|
||||
.btn.save:hover {
|
||||
background: #5a67d8;
|
||||
}
|
||||
|
||||
/* Camera modal styles */
|
||||
.camera-modal {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.2);
|
||||
z-index: 1300;
|
||||
width: 380px;
|
||||
max-width: calc(100vw - 32px);
|
||||
padding-bottom: 1.5rem;
|
||||
}
|
||||
.camera-header {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid #e6e6e6;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.camera-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.camera-error {
|
||||
color: #ff4d4f;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.camera-video {
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
border-radius: 12px;
|
||||
background: #222;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.camera-actions {
|
||||
padding: 0.8rem 1rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
.btn.capture {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
flex: 1;
|
||||
}
|
||||
.btn.capture:hover {
|
||||
background: #5a67d8;
|
||||
}
|
||||
.btn.confirm {
|
||||
background: #28a745;
|
||||
color: white;
|
||||
flex: 1;
|
||||
}
|
||||
.btn.confirm:hover {
|
||||
background: #218838;
|
||||
}
|
||||
|
||||
.captured-preview {
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
max-height: 240px;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 1rem;
|
||||
display: block;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
object-fit: contain;
|
||||
}
|
||||
</style>
|
||||
343
web/vue-app/src/components/ChildRewardList.vue
Normal file
@@ -0,0 +1,343 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
|
||||
import { defineProps, defineEmits, defineExpose } from 'vue'
|
||||
import { getCachedImageUrl, revokeAllImageUrls } from '../common/imageCache'
|
||||
|
||||
interface Reward {
|
||||
id: string
|
||||
name: string
|
||||
points_needed: number
|
||||
image_id: string | null
|
||||
}
|
||||
|
||||
const imageCacheName = 'images-v1'
|
||||
|
||||
const props = defineProps<{
|
||||
childId: string | number | null
|
||||
isParentAuthenticated: boolean
|
||||
}>()
|
||||
const emit = defineEmits(['points-updated'])
|
||||
|
||||
const rewards = ref<Reward[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const scrollWrapper = ref<HTMLDivElement | null>(null)
|
||||
const rewardRefs = ref<Record<string, HTMLElement | null>>({})
|
||||
const lastCenteredRewardId = ref<string | null>(null)
|
||||
const readyRewardId = ref<string | null>(null)
|
||||
|
||||
const fetchRewards = async (id: string | number | null) => {
|
||||
if (!id) {
|
||||
rewards.value = []
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/api/child/${id}/reward-status`)
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
const data = await resp.json()
|
||||
rewards.value = data.reward_status
|
||||
|
||||
// Fetch images for each reward using shared utility
|
||||
await Promise.all(rewards.value.map(fetchImage))
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to fetch rewards'
|
||||
console.error('Error fetching rewards:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const fetchImage = async (reward: Reward) => {
|
||||
if (!reward.image_id) {
|
||||
console.log(`No image ID for reward: ${reward.id}`)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const url = await getCachedImageUrl(reward.image_id, imageCacheName)
|
||||
reward.image_id = url
|
||||
} catch (err) {
|
||||
console.error('Error fetching image for reward', reward.id, err)
|
||||
}
|
||||
}
|
||||
|
||||
const centerReward = async (rewardId: string) => {
|
||||
await nextTick()
|
||||
const wrapper = scrollWrapper.value
|
||||
const card = rewardRefs.value[rewardId]
|
||||
if (wrapper && card) {
|
||||
const wrapperRect = wrapper.getBoundingClientRect()
|
||||
const cardRect = card.getBoundingClientRect()
|
||||
const wrapperScrollLeft = wrapper.scrollLeft
|
||||
const cardCenter = cardRect.left + cardRect.width / 2
|
||||
const wrapperCenter = wrapperRect.left + wrapperRect.width / 2
|
||||
const scrollOffset = cardCenter - wrapperCenter
|
||||
wrapper.scrollTo({
|
||||
left: wrapperScrollLeft + scrollOffset,
|
||||
behavior: 'smooth',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleRewardClick = async (rewardId: string) => {
|
||||
if (!props.isParentAuthenticated) return // Only allow if logged in
|
||||
|
||||
await nextTick()
|
||||
const wrapper = scrollWrapper.value
|
||||
const card = rewardRefs.value[rewardId]
|
||||
if (!wrapper || !card) return
|
||||
|
||||
const wrapperRect = wrapper.getBoundingClientRect()
|
||||
const cardRect = card.getBoundingClientRect()
|
||||
const cardCenter = cardRect.left + cardRect.width / 2
|
||||
const cardFullyVisible = cardCenter >= wrapperRect.left && cardCenter <= wrapperRect.right
|
||||
|
||||
if (!cardFullyVisible || lastCenteredRewardId.value !== rewardId) {
|
||||
// Center the reward, but don't trigger
|
||||
await centerReward(rewardId)
|
||||
lastCenteredRewardId.value = rewardId
|
||||
readyRewardId.value = rewardId
|
||||
return
|
||||
}
|
||||
|
||||
// If already centered and visible, trigger the reward
|
||||
await triggerReward(rewardId)
|
||||
readyRewardId.value = null
|
||||
}
|
||||
|
||||
const triggerReward = async (rewardId: string) => {
|
||||
if (!props.childId) return
|
||||
const reward = rewards.value.find((rew) => rew.id === rewardId)
|
||||
if (!reward || reward.points_needed > 0) return // Don't trigger if not allowed
|
||||
if (!props.isParentAuthenticated) return // Only allow if logged in
|
||||
try {
|
||||
const resp = await fetch(`/api/child/${props.childId}/trigger-reward`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ reward_id: rewardId }),
|
||||
})
|
||||
if (!resp.ok) return
|
||||
const data = await resp.json()
|
||||
// Emit the new points so the parent can update the child points
|
||||
emit('points-updated', { id: props.childId, points: data.points })
|
||||
} catch (err) {
|
||||
console.error('Failed to trigger reward:', err)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => fetchRewards(props.childId))
|
||||
watch(
|
||||
() => props.childId,
|
||||
(v) => fetchRewards(v),
|
||||
)
|
||||
|
||||
// revoke created object URLs when component unmounts to avoid memory leaks
|
||||
onBeforeUnmount(() => {
|
||||
revokeAllImageUrls()
|
||||
})
|
||||
|
||||
// expose refresh method for parent component
|
||||
defineExpose({ refresh: () => fetchRewards(props.childId) })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="reward-list-container">
|
||||
<h3>Rewards</h3>
|
||||
|
||||
<div v-if="loading" class="loading">Loading rewards...</div>
|
||||
<div v-else-if="error" class="error">Error: {{ error }}</div>
|
||||
<div v-else-if="rewards.length === 0" class="empty">No rewards available</div>
|
||||
|
||||
<div v-else class="scroll-wrapper" ref="scrollWrapper">
|
||||
<div class="reward-scroll">
|
||||
<div
|
||||
v-for="r in rewards"
|
||||
:key="r.id"
|
||||
class="reward-card"
|
||||
:class="{
|
||||
ready: readyRewardId === r.id,
|
||||
disabled: r.points_needed > 0,
|
||||
}"
|
||||
:ref="(el) => (rewardRefs[r.id] = el)"
|
||||
@click="() => handleRewardClick(r.id)"
|
||||
>
|
||||
<div class="reward-name">{{ r.name }}</div>
|
||||
<img v-if="r.image_id" :src="r.image_id" alt="Reward Image" class="reward-image" />
|
||||
<div class="reward-points" :class="{ ready: r.points_needed === 0 }">
|
||||
<template v-if="r.points_needed === 0"> REWARD READY </template>
|
||||
<template v-else> {{ r.points_needed }} pts needed </template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.reward-list-container {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-radius: 12px;
|
||||
padding: 0.9rem;
|
||||
color: white;
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.reward-list-container h3 {
|
||||
margin: 0 0 0.75rem 0;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.error,
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.scroll-wrapper {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scroll-behavior: smooth;
|
||||
width: 100%;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* Modern scrollbar styling */
|
||||
.scroll-wrapper::-webkit-scrollbar {
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.scroll-wrapper::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.scroll-wrapper::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(180deg, rgba(102, 126, 234, 0.8), rgba(118, 75, 162, 0.8));
|
||||
border-radius: 10px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.scroll-wrapper::-webkit-scrollbar-thumb:hover {
|
||||
background: linear-gradient(180deg, rgba(102, 126, 234, 1), rgba(118, 75, 162, 1));
|
||||
box-shadow: 0 0 8px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.reward-scroll {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
min-width: min-content;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.reward-card {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
min-width: 140px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.18s ease;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.reward-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.reward-card.ready {
|
||||
box-shadow:
|
||||
0 0 0 3px #ffd166cc,
|
||||
0 0 12px #ffd16688;
|
||||
border-color: #ffd166;
|
||||
animation: ready-glow 0.7s;
|
||||
}
|
||||
|
||||
.reward-card.disabled {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
filter: grayscale(0.7);
|
||||
}
|
||||
|
||||
@keyframes ready-glow {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 #ffd16600;
|
||||
border-color: inherit;
|
||||
}
|
||||
100% {
|
||||
box-shadow:
|
||||
0 0 0 3px #ffd166cc,
|
||||
0 0 12px #ffd16688;
|
||||
border-color: #ffd166;
|
||||
}
|
||||
}
|
||||
|
||||
.reward-name {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.4rem;
|
||||
color: #fff;
|
||||
line-height: 1.2;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Image styling */
|
||||
.reward-image {
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
margin: 0 auto 0.4rem auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.reward-points {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: #ffd166;
|
||||
}
|
||||
|
||||
.reward-points.ready {
|
||||
color: #38c172; /* a nice green */
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* Mobile tweaks */
|
||||
@media (max-width: 480px) {
|
||||
.reward-card {
|
||||
min-width: 110px;
|
||||
padding: 0.6rem;
|
||||
}
|
||||
.reward-name {
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
.reward-image {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
margin: 0 auto 0.3rem auto;
|
||||
}
|
||||
.reward-points {
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.scroll-wrapper::-webkit-scrollbar {
|
||||
height: 10px;
|
||||
}
|
||||
.scroll-wrapper::-webkit-scrollbar-thumb {
|
||||
border-width: 1px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
343
web/vue-app/src/components/ChildTaskList.vue
Normal file
@@ -0,0 +1,343 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { defineProps, defineEmits } from 'vue'
|
||||
import { getCachedImageUrl, revokeAllImageUrls } from '../common/imageCache'
|
||||
|
||||
interface Task {
|
||||
id: string
|
||||
name: string
|
||||
points: number
|
||||
is_good: boolean
|
||||
image_id: string | null // Ensure image can be null or hold an object URL
|
||||
}
|
||||
|
||||
const imageCacheName = 'images-v1'
|
||||
|
||||
const props = defineProps<{
|
||||
taskIds: string[]
|
||||
childId: string | number | null
|
||||
isParentAuthenticated: boolean
|
||||
}>()
|
||||
const emit = defineEmits(['points-updated'])
|
||||
|
||||
const tasks = ref<Task[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
const scrollWrapper = ref<HTMLDivElement | null>(null)
|
||||
const taskRefs = ref<Record<string, HTMLElement | null>>({})
|
||||
|
||||
const lastCenteredTaskId = ref<string | null>(null)
|
||||
const lastCenterTime = ref<number>(0)
|
||||
const readyTaskId = ref<string | null>(null)
|
||||
|
||||
const fetchTasks = async () => {
|
||||
const taskPromises = props.taskIds.map((id) =>
|
||||
fetch(`/api/task/${id}`).then((res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
return res.json()
|
||||
}),
|
||||
)
|
||||
|
||||
try {
|
||||
const results = await Promise.all(taskPromises)
|
||||
tasks.value = results
|
||||
|
||||
// Fetch images for each task (uses shared imageCache)
|
||||
await Promise.all(tasks.value.map(fetchImage))
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to fetch tasks'
|
||||
console.error('Error fetching tasks:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const fetchImage = async (task: Task) => {
|
||||
if (!task.image_id) {
|
||||
console.log(`No image ID for task: ${task.id}`)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const url = await getCachedImageUrl(task.image_id, imageCacheName)
|
||||
task.image_id = url
|
||||
} catch (err) {
|
||||
console.error('Error fetching image for task', task.id, err)
|
||||
}
|
||||
}
|
||||
|
||||
const centerTask = async (taskId: string) => {
|
||||
await nextTick()
|
||||
const wrapper = scrollWrapper.value
|
||||
const card = taskRefs.value[taskId]
|
||||
if (wrapper && card) {
|
||||
const wrapperRect = wrapper.getBoundingClientRect()
|
||||
const cardRect = card.getBoundingClientRect()
|
||||
const wrapperScrollLeft = wrapper.scrollLeft
|
||||
const cardCenter = cardRect.left + cardRect.width / 2
|
||||
const wrapperCenter = wrapperRect.left + wrapperRect.width / 2
|
||||
const scrollOffset = cardCenter - wrapperCenter
|
||||
wrapper.scrollTo({
|
||||
left: wrapperScrollLeft + scrollOffset,
|
||||
behavior: 'smooth',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const triggerTask = async (taskId: string) => {
|
||||
if (!props.isParentAuthenticated || !props.childId) return
|
||||
try {
|
||||
const resp = await fetch(`/api/child/${props.childId}/trigger-task`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ task_id: taskId }),
|
||||
})
|
||||
if (!resp.ok) return
|
||||
const data = await resp.json()
|
||||
emit('points-updated', { id: data.id, points: data.points })
|
||||
} catch (err) {
|
||||
console.error('Failed to trigger task:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTaskClick = async (taskId: string) => {
|
||||
await nextTick()
|
||||
const wrapper = scrollWrapper.value
|
||||
const card = taskRefs.value[taskId]
|
||||
if (!wrapper || !card) return
|
||||
|
||||
const wrapperRect = wrapper.getBoundingClientRect()
|
||||
const cardRect = card.getBoundingClientRect()
|
||||
const cardCenter = cardRect.left + cardRect.width / 2
|
||||
const cardFullyVisible = cardCenter >= wrapperRect.left && cardCenter <= wrapperRect.right
|
||||
|
||||
if (!cardFullyVisible || lastCenteredTaskId.value !== taskId) {
|
||||
// Center the task, but don't trigger
|
||||
await centerTask(taskId)
|
||||
lastCenteredTaskId.value = taskId
|
||||
lastCenterTime.value = Date.now()
|
||||
readyTaskId.value = taskId // <-- Add this line
|
||||
return
|
||||
}
|
||||
|
||||
// If already centered and visible, trigger the task
|
||||
triggerTask(taskId)
|
||||
readyTaskId.value = null
|
||||
}
|
||||
|
||||
onMounted(fetchTasks)
|
||||
|
||||
// revoke all created object URLs when component unmounts
|
||||
onBeforeUnmount(() => {
|
||||
revokeAllImageUrls()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="task-list-container">
|
||||
<h3>Tasks</h3>
|
||||
|
||||
<div v-if="loading" class="loading">Loading tasks...</div>
|
||||
<div v-else-if="error" class="error">Error: {{ error }}</div>
|
||||
<div v-else-if="tasks.length === 0" class="empty">No tasks</div>
|
||||
|
||||
<div v-else class="scroll-wrapper" ref="scrollWrapper">
|
||||
<div class="task-scroll">
|
||||
<div
|
||||
v-for="task in tasks"
|
||||
:key="task.id"
|
||||
class="task-card"
|
||||
:class="{ good: task.is_good, bad: !task.is_good, ready: readyTaskId === task.id }"
|
||||
:ref="(el) => (taskRefs[task.id] = el)"
|
||||
@click="() => handleTaskClick(task.id)"
|
||||
>
|
||||
<div class="task-name">{{ task.name }}</div>
|
||||
<img v-if="task.image_id" :src="task.image_id" alt="Task Image" class="task-image" />
|
||||
<div
|
||||
class="task-points"
|
||||
:class="{ 'good-points': task.is_good, 'bad-points': !task.is_good }"
|
||||
>
|
||||
{{ task.is_good ? task.points : -task.points }} pts
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.task-list-container {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
color: white;
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.task-list-container h3 {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.error,
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
font-size: 0.9rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
.scroll-wrapper {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scroll-behavior: smooth;
|
||||
width: 100%;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* Modern scrollbar styling */
|
||||
.scroll-wrapper::-webkit-scrollbar {
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.scroll-wrapper::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.scroll-wrapper::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(180deg, rgba(102, 126, 234, 0.8), rgba(118, 75, 162, 0.8));
|
||||
border-radius: 10px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.scroll-wrapper::-webkit-scrollbar-thumb:hover {
|
||||
background: linear-gradient(180deg, rgba(102, 126, 234, 1), rgba(118, 75, 162, 1));
|
||||
box-shadow: 0 0 8px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.task-scroll {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
min-width: min-content;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.task-card {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
min-width: 110px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.2s ease;
|
||||
border: 2px solid rgba(255, 255, 255, 0.15);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Outline colors depending on is_good */
|
||||
.task-card.good {
|
||||
border-color: rgba(46, 204, 113, 0.9); /* green */
|
||||
background: rgba(46, 204, 113, 0.06);
|
||||
}
|
||||
|
||||
.task-card.bad {
|
||||
border-color: rgba(255, 99, 71, 0.95); /* red */
|
||||
background: rgba(255, 99, 71, 0.03);
|
||||
}
|
||||
|
||||
.task-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.task-card.ready {
|
||||
box-shadow:
|
||||
0 0 0 3px #667eea88,
|
||||
0 0 12px #667eea44;
|
||||
border-color: #667eea;
|
||||
animation: ready-glow 0.7s;
|
||||
}
|
||||
|
||||
@keyframes ready-glow {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 #667eea00;
|
||||
border-color: inherit;
|
||||
}
|
||||
100% {
|
||||
box-shadow:
|
||||
0 0 0 3px #667eea88,
|
||||
0 0 12px #667eea44;
|
||||
border-color: #667eea;
|
||||
}
|
||||
}
|
||||
|
||||
.task-name {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.4rem;
|
||||
word-break: break-word;
|
||||
line-height: 1.2;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.task-points {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.task-points.good-points {
|
||||
color: #2ecc71;
|
||||
}
|
||||
|
||||
.task-points.bad-points {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
/* Image styling */
|
||||
.task-image {
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
margin: 0 auto 0.4rem auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Mobile tweaks */
|
||||
@media (max-width: 480px) {
|
||||
.task-list-container {
|
||||
padding: 0.75rem;
|
||||
max-width: 100%;
|
||||
}
|
||||
.task-card {
|
||||
min-width: 90px;
|
||||
padding: 0.6rem;
|
||||
}
|
||||
.task-name {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.task-image {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
margin: 0 auto 0.3rem auto;
|
||||
}
|
||||
.task-points {
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
.scroll-wrapper::-webkit-scrollbar {
|
||||
height: 10px;
|
||||
}
|
||||
.scroll-wrapper::-webkit-scrollbar-thumb {
|
||||
border-width: 1px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
163
web/vue-app/src/components/ChildView.vue
Normal file
@@ -0,0 +1,163 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import ChildDetailCard from './ChildDetailCard.vue'
|
||||
import ChildTaskList from './ChildTaskList.vue'
|
||||
import ChildRewardList from './ChildRewardList.vue'
|
||||
|
||||
interface Child {
|
||||
id: string | number
|
||||
name: string
|
||||
age: number
|
||||
points?: number
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const child = ref<Child | null>(null)
|
||||
const tasks = ref<string[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const resp = await fetch(`/api/child/${route.params.id}`)
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
const data = await resp.json()
|
||||
child.value = data.children ? data.children : data
|
||||
tasks.value = data.tasks || []
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to fetch child'
|
||||
console.error(err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
<div v-else-if="error" class="error">Error: {{ error }}</div>
|
||||
|
||||
<div v-else class="layout">
|
||||
<div class="main">
|
||||
<ChildDetailCard :child="child" />
|
||||
<ChildTaskList
|
||||
:task-ids="tasks"
|
||||
:child-id="child ? child.id : null"
|
||||
:is-parent-authenticated="false"
|
||||
/>
|
||||
<ChildRewardList
|
||||
:child-id="child ? child.id : null"
|
||||
:is-parent-authenticated="false"
|
||||
@points-updated="
|
||||
({ id, points }) => {
|
||||
if (child && child.id === id) child.points = points
|
||||
}
|
||||
"
|
||||
/>
|
||||
<!-- removed placeholder -->
|
||||
</div>
|
||||
<!-- Remove this aside block:
|
||||
<aside class="side">
|
||||
<div class="placeholder">Additional components go here</div>
|
||||
</aside>
|
||||
-->
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
min-height: 100vh;
|
||||
padding: 2rem;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.back-btn {
|
||||
background: white;
|
||||
border: 0;
|
||||
padding: 0.6rem 1rem;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
margin-bottom: 1.5rem;
|
||||
color: #667eea;
|
||||
font-weight: 600;
|
||||
}
|
||||
.loading,
|
||||
.error {
|
||||
color: white;
|
||||
min-height: 200px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
.error {
|
||||
color: #ff6b6b;
|
||||
background: rgba(255, 107, 107, 0.1);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
}
|
||||
.layout {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
/* Remove grid styles */
|
||||
/* grid-template-columns: 1fr 320px; */
|
||||
/* gap: 1.5rem; */
|
||||
}
|
||||
.main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
width: 100%;
|
||||
max-width: 600px; /* or whatever width fits your content best */
|
||||
}
|
||||
.side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
.placeholder {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: white;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
min-height: 120px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Mobile adjustments */
|
||||
@media (max-width: 900px) {
|
||||
.layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.container {
|
||||
padding: 1rem;
|
||||
}
|
||||
.back-btn {
|
||||
padding: 0.45rem 0.75rem;
|
||||
font-size: 0.95rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.main {
|
||||
gap: 1rem;
|
||||
}
|
||||
.placeholder {
|
||||
padding: 0.75rem;
|
||||
min-height: 80px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
524
web/vue-app/src/components/ChildrenList.vue
Normal file
@@ -0,0 +1,524 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { getCachedImageUrl, revokeAllImageUrls } from '../common/imageCache'
|
||||
import { isParentAuthenticated } from '../stores/auth'
|
||||
import ChildForm from './ChildForm.vue'
|
||||
|
||||
interface Child {
|
||||
id: string | number
|
||||
name: string
|
||||
age: number
|
||||
points?: number
|
||||
image_id: string | null
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const children = ref<Child[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
const images = ref<Map<string, string>>(new Map()) // Store image URLs
|
||||
|
||||
const imageCacheName = 'images-v1'
|
||||
|
||||
// UI state for kebab menus & delete confirmation
|
||||
const activeMenuFor = ref<string | number | null>(null) // which child card shows menu
|
||||
const confirmDeleteVisible = ref(false)
|
||||
const deletingChildId = ref<string | number | null>(null)
|
||||
const deleting = ref(false)
|
||||
|
||||
const showEditDialog = ref(false)
|
||||
const editingChild = ref<Child | null>(null)
|
||||
|
||||
const openEditDialog = (child: Child, evt?: Event) => {
|
||||
evt?.stopPropagation()
|
||||
editingChild.value = { ...child } // shallow copy for editing
|
||||
showEditDialog.value = true
|
||||
closeMenu()
|
||||
}
|
||||
|
||||
const closeEditDialog = () => {
|
||||
showEditDialog.value = false
|
||||
editingChild.value = null
|
||||
}
|
||||
|
||||
// points update state
|
||||
const updatingPointsFor = ref<string | number | null>(null)
|
||||
|
||||
const fetchImage = async (imageId: string) => {
|
||||
try {
|
||||
const url = await getCachedImageUrl(imageId, imageCacheName)
|
||||
images.value.set(imageId, url)
|
||||
} catch (err) {
|
||||
console.warn('Failed to load child image', imageId, err)
|
||||
}
|
||||
}
|
||||
|
||||
// extracted fetch so we can refresh after delete / points edit
|
||||
const fetchChildren = async () => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
images.value.clear()
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/child/list')
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
const data = await response.json()
|
||||
children.value = data.children || []
|
||||
|
||||
// Fetch images for each child (shared cache util)
|
||||
await Promise.all(
|
||||
children.value.map((child) => {
|
||||
if (child.image_id) {
|
||||
return fetchImage(child.image_id)
|
||||
}
|
||||
return Promise.resolve()
|
||||
}),
|
||||
)
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to fetch children'
|
||||
console.error('Error fetching children:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchChildren()
|
||||
// listen for outside clicks to auto-close any open kebab menu
|
||||
document.addEventListener('click', onDocClick, true)
|
||||
})
|
||||
|
||||
const shouldIgnoreNextCardClick = ref(false)
|
||||
|
||||
const onDocClick = (e: MouseEvent) => {
|
||||
if (activeMenuFor.value !== null) {
|
||||
const path = (e.composedPath && e.composedPath()) || (e as any).path || []
|
||||
const clickedInsideKebab = path.some((node: unknown) => {
|
||||
if (!(node instanceof HTMLElement)) return false
|
||||
return (
|
||||
node.classList.contains('kebab-wrap') ||
|
||||
node.classList.contains('kebab-btn') ||
|
||||
node.classList.contains('kebab-menu')
|
||||
)
|
||||
})
|
||||
if (!clickedInsideKebab) {
|
||||
activeMenuFor.value = null
|
||||
// If the click was on a card, set the flag to ignore the next card click
|
||||
if (
|
||||
path.some((node: unknown) => node instanceof HTMLElement && node.classList.contains('card'))
|
||||
) {
|
||||
shouldIgnoreNextCardClick.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const selectChild = (childId: string | number) => {
|
||||
if (shouldIgnoreNextCardClick.value) {
|
||||
shouldIgnoreNextCardClick.value = false
|
||||
return
|
||||
}
|
||||
if (activeMenuFor.value !== null) {
|
||||
// If kebab menu is open, ignore card clicks
|
||||
return
|
||||
}
|
||||
if (isParentAuthenticated.value) {
|
||||
router.push(`/parent/${childId}`)
|
||||
} else {
|
||||
router.push(`/child/${childId}`)
|
||||
}
|
||||
}
|
||||
|
||||
// kebab menu helpers
|
||||
const openMenu = (childId: string | number, evt?: Event) => {
|
||||
evt?.stopPropagation()
|
||||
activeMenuFor.value = childId
|
||||
}
|
||||
const closeMenu = () => {
|
||||
console.log('Closing menu')
|
||||
activeMenuFor.value = null
|
||||
}
|
||||
|
||||
// delete flow
|
||||
const askDelete = (childId: string | number, evt?: Event) => {
|
||||
evt?.stopPropagation()
|
||||
deletingChildId.value = childId
|
||||
confirmDeleteVisible.value = true
|
||||
closeMenu()
|
||||
}
|
||||
|
||||
const performDelete = async () => {
|
||||
if (!deletingChildId.value) return
|
||||
deleting.value = true
|
||||
try {
|
||||
const resp = await fetch(`/api/child/${deletingChildId.value}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Delete failed: ${resp.status}`)
|
||||
}
|
||||
// refresh list
|
||||
await fetchChildren()
|
||||
} catch (err) {
|
||||
console.error('Failed to delete child', deletingChildId.value, err)
|
||||
} finally {
|
||||
deleting.value = false
|
||||
confirmDeleteVisible.value = false
|
||||
deletingChildId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// Delete Points flow: set points to 0 via API and refresh points display
|
||||
const deletePoints = async (childId: string | number, evt?: Event) => {
|
||||
evt?.stopPropagation()
|
||||
closeMenu()
|
||||
updatingPointsFor.value = childId
|
||||
try {
|
||||
const resp = await fetch(`/api/child/${childId}/edit`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ points: 0 }),
|
||||
})
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Failed to update points: ${resp.status}`)
|
||||
}
|
||||
// refresh the list so points reflect the change
|
||||
await fetchChildren()
|
||||
} catch (err) {
|
||||
console.error('Failed to delete points for child', childId, err)
|
||||
} finally {
|
||||
updatingPointsFor.value = null
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', onDocClick, true)
|
||||
revokeAllImageUrls()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<div v-else-if="error" class="error">Error: {{ error }}</div>
|
||||
|
||||
<div v-else-if="children.length === 0" class="empty">No children found</div>
|
||||
|
||||
<div v-else class="grid">
|
||||
<div v-for="child in children" :key="child.id" class="card" @click="selectChild(child.id)">
|
||||
<!-- kebab menu shown only for authenticated parent -->
|
||||
<div v-if="isParentAuthenticated" class="kebab-wrap" @click.stop>
|
||||
<!-- kebab button -->
|
||||
<button
|
||||
class="kebab-btn"
|
||||
@mousedown.stop.prevent
|
||||
@click="openMenu(child.id, $event)"
|
||||
:aria-expanded="activeMenuFor === child.id ? 'true' : 'false'"
|
||||
aria-label="Options"
|
||||
>
|
||||
⋮
|
||||
</button>
|
||||
|
||||
<!-- menu items -->
|
||||
<div
|
||||
v-if="activeMenuFor === child.id"
|
||||
class="kebab-menu"
|
||||
@mousedown.stop.prevent
|
||||
@click.stop
|
||||
>
|
||||
<button
|
||||
class="menu-item"
|
||||
@mousedown.stop.prevent
|
||||
@click="openEditDialog(child, $event)"
|
||||
>
|
||||
Edit Child
|
||||
</button>
|
||||
<button
|
||||
class="menu-item"
|
||||
@mousedown.stop.prevent
|
||||
@click="deletePoints(child.id, $event)"
|
||||
:disabled="updatingPointsFor === child.id"
|
||||
>
|
||||
{{ updatingPointsFor === child.id ? 'Updating…' : 'Delete Points' }}
|
||||
</button>
|
||||
<button class="menu-item danger" @mousedown.stop.prevent @click="askDelete(child.id)">
|
||||
Delete Child
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<h2>{{ child.name }}</h2>
|
||||
<img
|
||||
v-if="images.get(child.image_id)"
|
||||
:src="images.get(child.image_id)"
|
||||
alt="Child Image"
|
||||
class="child-image"
|
||||
/>
|
||||
<p class="age">Age: {{ child.age }}</p>
|
||||
<p class="points">Points: {{ child.points ?? 0 }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ChildForm
|
||||
v-if="showEditDialog"
|
||||
:child="editingChild"
|
||||
@close="closeEditDialog"
|
||||
@updated="
|
||||
async () => {
|
||||
closeEditDialog()
|
||||
await fetchChildren()
|
||||
}
|
||||
"
|
||||
/>
|
||||
|
||||
<!-- confirmation modal -->
|
||||
<div
|
||||
v-if="confirmDeleteVisible"
|
||||
class="modal-backdrop"
|
||||
@click.self="confirmDeleteVisible = false"
|
||||
>
|
||||
<div class="modal">
|
||||
<h3>Delete child?</h3>
|
||||
<p>Are you sure you want to permanently delete this child?</p>
|
||||
<div class="actions">
|
||||
<button
|
||||
class="btn cancel"
|
||||
@click="
|
||||
() => {
|
||||
confirmDeleteVisible = false
|
||||
deletingChildId = null
|
||||
}
|
||||
"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button class="btn delete" @click="performDelete" :disabled="deleting">
|
||||
{{ deleting ? 'Deleting…' : 'Confirm Delete' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: white;
|
||||
margin-bottom: 2rem;
|
||||
font-size: 2.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.error,
|
||||
.empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 200px;
|
||||
color: white;
|
||||
font-size: 1.2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #ff6b6b;
|
||||
background: rgba(255, 107, 107, 0.1);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 1.5rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
|
||||
overflow: visible; /* allow menu to overflow */
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
cursor: pointer;
|
||||
position: relative; /* for kebab positioning */
|
||||
}
|
||||
|
||||
/* kebab button / menu (fixed-size button, absolutely positioned menu) */
|
||||
.kebab-wrap {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
z-index: 20;
|
||||
/* keep the wrapper only as a positioning context */
|
||||
}
|
||||
|
||||
.kebab-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
color: #666;
|
||||
border-radius: 6px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* consistent focus ring without changing layout */
|
||||
.kebab-btn:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.18);
|
||||
}
|
||||
|
||||
/* Menu overlays the card and does NOT alter flow */
|
||||
.kebab-menu {
|
||||
position: absolute;
|
||||
top: 44px; /* place below the button */
|
||||
right: 0; /* align to kebab button's right edge */
|
||||
margin: 0;
|
||||
min-width: 150px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
padding: 0.6rem 0.9rem;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.menu-item:hover {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.menu-item.danger {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
/* card content */
|
||||
.card-content {
|
||||
padding: 1.5rem;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
font-size: 1.5rem;
|
||||
color: #333;
|
||||
margin-bottom: 0.5rem;
|
||||
word-break: break-word;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.age {
|
||||
font-size: 1.1rem;
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.child-image {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
object-fit: cover;
|
||||
border-radius: 50%;
|
||||
margin: 0 auto 1rem auto;
|
||||
}
|
||||
|
||||
/* modal */
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1200;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: #fff;
|
||||
color: #222;
|
||||
padding: 1.25rem;
|
||||
border-radius: 10px;
|
||||
width: 360px;
|
||||
max-width: calc(100% - 32px);
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.2);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.modal h3 {
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: center;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.5rem 0.8rem;
|
||||
border-radius: 8px;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.btn.cancel {
|
||||
background: #f3f3f3;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.btn.delete {
|
||||
background: #ff4d4f;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.points {
|
||||
font-size: 1.05rem;
|
||||
color: #444;
|
||||
margin-top: 0.4rem;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
161
web/vue-app/src/components/LoginButton.vue
Normal file
@@ -0,0 +1,161 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { authenticateParent, isParentAuthenticated, logout } from '../stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const show = ref(false)
|
||||
const pin = ref('')
|
||||
const error = ref('')
|
||||
const pinInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const open = async () => {
|
||||
pin.value = ''
|
||||
error.value = ''
|
||||
show.value = true
|
||||
await nextTick()
|
||||
pinInput.value?.focus()
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
show.value = false
|
||||
error.value = ''
|
||||
}
|
||||
|
||||
const submit = () => {
|
||||
const isDigits = /^\d{4,6}$/.test(pin.value)
|
||||
if (!isDigits) {
|
||||
error.value = 'Enter 4–6 digits'
|
||||
return
|
||||
}
|
||||
|
||||
// Authenticate parent and navigate
|
||||
authenticateParent()
|
||||
close()
|
||||
router.push('/parent')
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
logout()
|
||||
router.push('/child')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="login-root">
|
||||
<button v-if="!isParentAuthenticated" class="login-btn" @click="open" aria-label="Parent login">
|
||||
Parent
|
||||
</button>
|
||||
<button v-else class="login-btn" @click="handleLogout" aria-label="Parent logout">
|
||||
Log out
|
||||
</button>
|
||||
|
||||
<div v-if="show" class="modal-backdrop" @click.self="close">
|
||||
<div class="modal">
|
||||
<h3>Enter parent PIN</h3>
|
||||
<form @submit.prevent="submit">
|
||||
<input
|
||||
ref="pinInput"
|
||||
v-model="pin"
|
||||
inputmode="numeric"
|
||||
pattern="\d*"
|
||||
maxlength="6"
|
||||
placeholder="4–6 digits"
|
||||
class="pin-input"
|
||||
/>
|
||||
<div class="actions">
|
||||
<button type="button" class="btn cancel" @click="close">Cancel</button>
|
||||
<button type="submit" class="btn submit">OK</button>
|
||||
</div>
|
||||
</form>
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login-root {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
background: white;
|
||||
color: #667eea;
|
||||
border: 0;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
/* modal */
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1200;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: #fff;
|
||||
color: #222;
|
||||
padding: 1.25rem;
|
||||
border-radius: 10px;
|
||||
width: 320px;
|
||||
max-width: calc(100% - 32px);
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.2);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.modal h3 {
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.pin-input {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.6rem;
|
||||
font-size: 1rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e6e6e6;
|
||||
margin-bottom: 0.6rem;
|
||||
box-sizing: border-box;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: center;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.45rem 0.7rem;
|
||||
border-radius: 8px;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn.cancel {
|
||||
background: #f3f3f3;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.btn.submit {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #ff6b6b;
|
||||
font-size: 0.9rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
</style>
|
||||
174
web/vue-app/src/components/ParentView.vue
Normal file
@@ -0,0 +1,174 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { isParentAuthenticated } from '../stores/auth'
|
||||
import ChildDetailCard from './ChildDetailCard.vue'
|
||||
import ChildTaskList from './ChildTaskList.vue'
|
||||
import ChildRewardList from './ChildRewardList.vue'
|
||||
import AssignTaskButton from './AssignTaskButton.vue' // <-- Import here
|
||||
|
||||
interface Child {
|
||||
id: string | number
|
||||
name: string
|
||||
age: number
|
||||
points?: number
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const child = ref<Child | null>(null)
|
||||
const tasks = ref<string[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
const rewardListRef = ref()
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const resp = await fetch(`/api/child/${route.params.id}`)
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
const data = await resp.json()
|
||||
child.value = data.children ? data.children : data
|
||||
tasks.value = data.tasks || []
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to fetch child'
|
||||
console.error(err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
const refreshRewards = () => {
|
||||
rewardListRef.value?.refresh()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
<div v-else-if="error" class="error">Error: {{ error }}</div>
|
||||
|
||||
<div v-else class="layout">
|
||||
<div class="main">
|
||||
<ChildDetailCard :child="child" />
|
||||
<ChildTaskList
|
||||
:task-ids="tasks"
|
||||
:child-id="child ? child.id : null"
|
||||
:is-parent-authenticated="isParentAuthenticated"
|
||||
@points-updated="
|
||||
({ id, points }) => {
|
||||
if (child && child.id === id) child.points = points
|
||||
refreshRewards()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<ChildRewardList
|
||||
ref="rewardListRef"
|
||||
:child-id="child ? child.id : null"
|
||||
:is-parent-authenticated="isParentAuthenticated"
|
||||
@points-updated="
|
||||
({ id, points }) => {
|
||||
if (child && child.id === id) child.points = points
|
||||
refreshRewards()
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Place the AssignTaskButton here, outside .main but inside .container -->
|
||||
<AssignTaskButton :child-id="child ? child.id : null" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
min-height: 100vh;
|
||||
padding: 2rem;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.back-btn {
|
||||
background: white;
|
||||
border: 0;
|
||||
padding: 0.6rem 1rem;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
margin-bottom: 1.5rem;
|
||||
color: #667eea;
|
||||
font-weight: 600;
|
||||
}
|
||||
.loading,
|
||||
.error {
|
||||
color: white;
|
||||
min-height: 200px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
.error {
|
||||
color: #ff6b6b;
|
||||
background: rgba(255, 107, 107, 0.1);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
}
|
||||
.layout {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
/* Remove grid styles */
|
||||
/* grid-template-columns: 1fr 320px; */
|
||||
/* gap: 1.5rem; */
|
||||
}
|
||||
.main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
width: 100%;
|
||||
max-width: 600px; /* or whatever width fits your content best */
|
||||
}
|
||||
.side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
.placeholder {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: white;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
min-height: 120px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Mobile adjustments */
|
||||
@media (max-width: 900px) {
|
||||
.layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.container {
|
||||
padding: 1rem;
|
||||
}
|
||||
.back-btn {
|
||||
padding: 0.45rem 0.75rem;
|
||||
font-size: 0.95rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.main {
|
||||
gap: 1rem;
|
||||
}
|
||||
.placeholder {
|
||||
padding: 0.75rem;
|
||||
min-height: 80px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
105
web/vue-app/src/layout/ChildLayout.vue
Normal file
@@ -0,0 +1,105 @@
|
||||
<script setup lang="ts">
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { computed } from 'vue'
|
||||
import LoginButton from '../components/LoginButton.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const handleBack = () => {
|
||||
if (window.history.length > 1) {
|
||||
router.back()
|
||||
} else {
|
||||
router.push('/child')
|
||||
}
|
||||
}
|
||||
|
||||
const showBack = computed(() => route.path !== '/child')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="layout-root">
|
||||
<header class="topbar">
|
||||
<div class="topbar-inner">
|
||||
<button v-if="showBack" class="back-btn" @click="handleBack">← Back</button>
|
||||
<div class="spacer"></div>
|
||||
<LoginButton />
|
||||
</div>
|
||||
</header>
|
||||
<main class="main-content">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.layout-root {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
/* Reduce top padding */
|
||||
padding: 0.5rem 2rem 2rem 2rem;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
/* top bar holds login button at top-right */
|
||||
.topbar {
|
||||
width: 100%;
|
||||
padding: 12px 20px;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.topbar-inner {
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* spacer pushes button to the right */
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* main content remains centered */
|
||||
.main-content {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start; /* content starts higher */
|
||||
box-sizing: border-box;
|
||||
|
||||
/* Reduce top padding */
|
||||
padding: 4px 20px 40px;
|
||||
}
|
||||
|
||||
/* back button style */
|
||||
.back-btn {
|
||||
background: white;
|
||||
border: 0;
|
||||
padding: 0.6rem 1rem;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
margin-bottom: 0;
|
||||
color: #667eea;
|
||||
font-weight: 600;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
background-color: #764ba2;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.back-btn {
|
||||
padding: 0.45rem 0.75rem;
|
||||
font-size: 0.95rem;
|
||||
margin-bottom: 0.7rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
130
web/vue-app/src/layout/ParentLayout.vue
Normal file
@@ -0,0 +1,130 @@
|
||||
<script setup lang="ts">
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { computed } from 'vue'
|
||||
import LoginButton from '../components/LoginButton.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const handleBack = () => {
|
||||
if (window.history.length > 1) {
|
||||
router.back()
|
||||
} else {
|
||||
router.push('/child')
|
||||
}
|
||||
}
|
||||
|
||||
const showBack = computed(() => route.path !== '/parent')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="layout-root">
|
||||
<header class="topbar">
|
||||
<div class="topbar-inner">
|
||||
<button v-if="showBack" class="back-btn" @click="handleBack">← Back</button>
|
||||
<div class="spacer"></div>
|
||||
<LoginButton />
|
||||
</div>
|
||||
</header>
|
||||
<main class="main-content">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.layout-root {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
/* Reduce top padding */
|
||||
padding: 0.5rem 2rem 2rem 2rem;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
/* top bar holds title and logout button */
|
||||
.topbar {
|
||||
width: 100%;
|
||||
padding: 12px 20px;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.topbar-inner {
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 1.5rem;
|
||||
color: white;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* spacer pushes button to the right */
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.logout-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);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.logout-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
/* main content remains centered */
|
||||
.main-content {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start; /* content starts higher */
|
||||
box-sizing: border-box;
|
||||
|
||||
/* Reduce top padding */
|
||||
padding: 4px 20px 40px;
|
||||
}
|
||||
|
||||
/* back button specific styles */
|
||||
.back-btn {
|
||||
background: white;
|
||||
border: 0;
|
||||
padding: 0.6rem 1rem;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
margin-bottom: 0;
|
||||
color: #667eea;
|
||||
font-weight: 600;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.back-btn {
|
||||
padding: 0.45rem 0.75rem;
|
||||
font-size: 0.95rem;
|
||||
margin-bottom: 0.7rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
9
web/vue-app/src/main.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(router)
|
||||
|
||||
app.mount('#app')
|
||||
68
web/vue-app/src/router/index.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { isParentAuthenticated } from '../stores/auth'
|
||||
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'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/child',
|
||||
name: 'ChildLayout',
|
||||
component: ChildLayout,
|
||||
children: [
|
||||
{
|
||||
path: '', // /child
|
||||
name: 'ChildrenList',
|
||||
component: ChildrenList,
|
||||
},
|
||||
{
|
||||
path: ':id', // /child/:id
|
||||
name: 'ChildDetailView',
|
||||
component: ChildView,
|
||||
props: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/parent',
|
||||
name: 'ParentLayout',
|
||||
component: ParentLayout,
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
{
|
||||
path: '', // /parent
|
||||
name: 'ParentChildrenList',
|
||||
component: ChildrenList,
|
||||
},
|
||||
{
|
||||
path: ':id',
|
||||
name: 'ParentChildDetailView',
|
||||
component: ParentView,
|
||||
props: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
redirect: '/child',
|
||||
},
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes,
|
||||
})
|
||||
|
||||
// Auth guard
|
||||
router.beforeEach((to, from, next) => {
|
||||
if (to.meta.requiresAuth && !isParentAuthenticated.value) {
|
||||
// Redirect to /child if trying to access /parent without auth
|
||||
next('/child')
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
11
web/vue-app/src/stores/auth.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
export const isParentAuthenticated = ref(false)
|
||||
|
||||
export function authenticateParent() {
|
||||
isParentAuthenticated.value = true
|
||||
}
|
||||
|
||||
export function logout() {
|
||||
isParentAuthenticated.value = false
|
||||
}
|
||||
12
web/vue-app/tsconfig.app.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
||||
"exclude": ["src/**/__tests__/*"],
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
14
web/vue-app/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.vitest.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
19
web/vue-app/tsconfig.node.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"extends": "@tsconfig/node22/tsconfig.json",
|
||||
"include": [
|
||||
"vite.config.*",
|
||||
"vitest.config.*",
|
||||
"cypress.config.*",
|
||||
"nightwatch.conf.*",
|
||||
"playwright.config.*",
|
||||
"eslint.config.*"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"types": ["node"]
|
||||
}
|
||||
}
|
||||
11
web/vue-app/tsconfig.vitest.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "./tsconfig.app.json",
|
||||
"include": ["src/**/__tests__/*", "env.d.ts"],
|
||||
"exclude": [],
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.vitest.tsbuildinfo",
|
||||
|
||||
"lib": [],
|
||||
"types": ["node", "jsdom"]
|
||||
}
|
||||
}
|
||||
28
web/vue-app/vite.config.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
//import vueDevTools from 'vite-plugin-vue-devtools'
|
||||
import fs from 'fs'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue() /*vueDevTools()*/],
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
https: {
|
||||
key: fs.readFileSync('./192.168.1.102+1-key.pem'),
|
||||
cert: fs.readFileSync('./192.168.1.102+1.pem'),
|
||||
},
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://192.168.1.102:5000',
|
||||
changeOrigin: true,
|
||||
rewrite: (p) => p.replace(/^\/api/, ''),
|
||||
},
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
})
|
||||
14
web/vue-app/vitest.config.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { mergeConfig, defineConfig, configDefaults } from 'vitest/config'
|
||||
import viteConfig from './vite.config'
|
||||
|
||||
export default mergeConfig(
|
||||
viteConfig,
|
||||
defineConfig({
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
exclude: [...configDefaults.exclude, 'e2e/**'],
|
||||
root: fileURLToPath(new URL('./', import.meta.url)),
|
||||
},
|
||||
}),
|
||||
)
|
||||