starting refactor styling

This commit is contained in:
2026-01-10 22:19:23 -05:00
parent 9a6fbced15
commit a89d3d7313
24 changed files with 815 additions and 344 deletions

View File

@@ -1,7 +1,7 @@
import logging import logging
import secrets, jwt import secrets, jwt
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from models.user import User
from flask import Blueprint, request, jsonify, current_app from flask import Blueprint, request, jsonify, current_app
from flask_mail import Mail, Message from flask_mail import Mail, Message
from tinydb import Query from tinydb import Query
@@ -57,15 +57,17 @@ def signup():
token = secrets.token_urlsafe(32) token = secrets.token_urlsafe(32)
now_iso = datetime.utcnow().isoformat() now_iso = datetime.utcnow().isoformat()
users_db.insert({ user = User(
'first_name': data['first_name'], first_name=data['first_name'],
'last_name': data['last_name'], last_name=data['last_name'],
'email': data['email'], email=data['email'],
'password': data['password'], # Hash in production! password=data['password'], # Hash in production!
'verified': False, verified=False,
'verify_token': token, verify_token=token,
'verify_token_created': now_iso verify_token_created=now_iso,
}) image_id="boy01"
)
users_db.insert(user.to_dict())
send_verification_email(data['email'], token) send_verification_email(data['email'], token)
return jsonify({'message': 'User created, verification email sent'}), 201 return jsonify({'message': 'User created, verification email sent'}), 201
@@ -75,6 +77,7 @@ def verify():
status = 'success' status = 'success'
reason = '' reason = ''
code = '' code = ''
user_dict = None
user = None user = None
if not token: if not token:
@@ -82,13 +85,14 @@ def verify():
reason = 'Missing token' reason = 'Missing token'
code = MISSING_TOKEN code = MISSING_TOKEN
else: else:
user = users_db.get(Query().verify_token == token) user_dict = users_db.get(Query().verify_token == token)
user = User.from_dict(user_dict) if user_dict else None
if not user: if not user:
status = 'error' status = 'error'
reason = 'Invalid token' reason = 'Invalid token'
code = INVALID_TOKEN code = INVALID_TOKEN
else: else:
created_str = user.get('verify_token_created') created_str = user.verify_token_created
if not created_str: if not created_str:
status = 'error' status = 'error'
reason = 'Token timestamp missing' reason = 'Token timestamp missing'
@@ -100,14 +104,17 @@ def verify():
reason = 'Token expired' reason = 'Token expired'
code = TOKEN_EXPIRED code = TOKEN_EXPIRED
else: else:
users_db.update({'verified': True, 'verify_token': None, 'verify_token_created': None}, Query().verify_token == token) user.verified = True
user.verify_token = None
user.verify_token_created = None
users_db.update(user.to_dict(), Query().email == user.email)
http_status = 200 if status == 'success' else 400 http_status = 200 if status == 'success' else 400
if http_status == 200 and user is not None: ##user is verified, create the user's image directory if http_status == 200 and user is not None:
if 'email' not in user: if not user.email:
logger.error("Verified user has no email field.") logger.error("Verified user has no email field.")
else: else:
user_image_dir = get_user_image_dir(sanitize_email(user['email'])) user_image_dir = get_user_image_dir(sanitize_email(user.email))
os.makedirs(user_image_dir, exist_ok=True) os.makedirs(user_image_dir, exist_ok=True)
return jsonify({'status': status, 'reason': reason, 'code': code}), http_status return jsonify({'status': status, 'reason': reason, 'code': code}), http_status
@@ -119,23 +126,22 @@ def resend_verify():
if not email: if not email:
return jsonify({'error': 'Missing email', 'code': MISSING_EMAIL}), 400 return jsonify({'error': 'Missing email', 'code': MISSING_EMAIL}), 400
user = users_db.get(UserQuery.email == email) user_dict = users_db.get(UserQuery.email == email)
user = User.from_dict(user_dict) if user_dict else None
if not user: if not user:
return jsonify({'error': 'User not found', 'code': USER_NOT_FOUND}), 404 return jsonify({'error': 'User not found', 'code': USER_NOT_FOUND}), 404
if user.get('verified'): if user.verified:
return jsonify({'error': 'Account already verified', 'code': ALREADY_VERIFIED}), 400 return jsonify({'error': 'Account already verified', 'code': ALREADY_VERIFIED}), 400
token = secrets.token_urlsafe(32) token = secrets.token_urlsafe(32)
now_iso = datetime.utcnow().isoformat() now_iso = datetime.utcnow().isoformat()
users_db.update({ user.verify_token = token
'verify_token': token, user.verify_token_created = now_iso
'verify_token_created': now_iso users_db.update(user.to_dict(), UserQuery.email == email)
}, UserQuery.email == email)
send_verification_email(email, token) send_verification_email(email, token)
return jsonify({'message': 'Verification email resent'}), 200 return jsonify({'message': 'Verification email resent'}), 200
@auth_api.route('/login', methods=['POST']) @auth_api.route('/login', methods=['POST'])
def login(): def login():
data = request.get_json() data = request.get_json()
@@ -144,11 +150,12 @@ def login():
if not email or not password: if not email or not password:
return jsonify({'error': 'Missing email or password', 'code': MISSING_EMAIL_OR_PASSWORD}), 400 return jsonify({'error': 'Missing email or password', 'code': MISSING_EMAIL_OR_PASSWORD}), 400
user = users_db.get(UserQuery.email == email) user_dict = users_db.get(UserQuery.email == email)
if not user or user.get('password') != password: user = User.from_dict(user_dict) if user_dict else None
if not user or user.password != password:
return jsonify({'error': 'Invalid credentials', 'code': INVALID_CREDENTIALS}), 401 return jsonify({'error': 'Invalid credentials', 'code': INVALID_CREDENTIALS}), 401
if not user.get('verified'): if not user.verified:
return jsonify({'error': 'This account has not verified', 'code': NOT_VERIFIED}), 403 return jsonify({'error': 'This account has not verified', 'code': NOT_VERIFIED}), 403
payload = { payload = {
@@ -170,45 +177,39 @@ def me():
try: try:
payload = jwt.decode(token, current_app.config['SECRET_KEY'], algorithms=['HS256']) payload = jwt.decode(token, current_app.config['SECRET_KEY'], algorithms=['HS256'])
email = payload.get('email') email = payload.get('email')
user = users_db.get(UserQuery.email == email) user_dict = users_db.get(UserQuery.email == email)
user = User.from_dict(user_dict) if user_dict else None
if not user: if not user:
return jsonify({'error': 'User not found', 'code': USER_NOT_FOUND}), 404 return jsonify({'error': 'User not found', 'code': USER_NOT_FOUND}), 404
return jsonify({ return jsonify({
'email': user['email'], 'email': user.email,
'id': sanitize_email(user['email']), 'id': sanitize_email(user.email),
'first_name': user['first_name'], 'first_name': user.first_name,
'last_name': user['last_name'], 'last_name': user.last_name,
'verified': user['verified'] 'verified': user.verified
}), 200 }), 200
except jwt.ExpiredSignatureError: except jwt.ExpiredSignatureError:
return jsonify({'error': 'Token expired', 'code': TOKEN_EXPIRED}), 401 return jsonify({'error': 'Token expired', 'code': TOKEN_EXPIRED}), 401
except jwt.InvalidTokenError: except jwt.InvalidTokenError:
return jsonify({'error': 'Invalid token', 'code': INVALID_TOKEN}), 401 return jsonify({'error': 'Invalid token', 'code': INVALID_TOKEN}), 401
@auth_api.route('/logout', methods=['POST'])
def logout():
resp = jsonify({'message': 'Logged out'})
resp.set_cookie('token', '', expires=0, httponly=True, secure=True, samesite='Strict')
return resp, 200
@auth_api.route('/request-password-reset', methods=['POST']) @auth_api.route('/request-password-reset', methods=['POST'])
def request_password_reset(): def request_password_reset():
data = request.get_json() data = request.get_json()
email = data.get('email') email = data.get('email')
# Always return success for privacy
success_msg = 'If this email is registered, you will receive a password reset link shortly.' success_msg = 'If this email is registered, you will receive a password reset link shortly.'
if not email: if not email:
return jsonify({'error': 'Missing email', 'code': MISSING_EMAIL}), 400 return jsonify({'error': 'Missing email', 'code': MISSING_EMAIL}), 400
user = users_db.get(UserQuery.email == email) user_dict = users_db.get(UserQuery.email == email)
user = User.from_dict(user_dict) if user_dict else None
if user: if user:
token = secrets.token_urlsafe(32) token = secrets.token_urlsafe(32)
now_iso = datetime.utcnow().isoformat() now_iso = datetime.utcnow().isoformat()
users_db.update({ user.reset_token = token
'reset_token': token, user.reset_token_created = now_iso
'reset_token_created': now_iso users_db.update(user.to_dict(), UserQuery.email == email)
}, UserQuery.email == email)
send_reset_password_email(email, token) send_reset_password_email(email, token)
return jsonify({'message': success_msg}), 200 return jsonify({'message': success_msg}), 200
@@ -219,11 +220,12 @@ def validate_reset_token():
if not token: if not token:
return jsonify({'error': 'Missing token', 'code': MISSING_TOKEN}), 400 return jsonify({'error': 'Missing token', 'code': MISSING_TOKEN}), 400
user = users_db.get(UserQuery.reset_token == token) user_dict = users_db.get(UserQuery.reset_token == token)
user = User.from_dict(user_dict) if user_dict else None
if not user: if not user:
return jsonify({'error': 'Invalid token', 'code': INVALID_TOKEN}), 400 return jsonify({'error': 'Invalid token', 'code': INVALID_TOKEN}), 400
created_str = user.get('reset_token_created') created_str = user.reset_token_created
if not created_str: if not created_str:
return jsonify({'error': 'Token timestamp missing', 'code': TOKEN_TIMESTAMP_MISSING}), 400 return jsonify({'error': 'Token timestamp missing', 'code': TOKEN_TIMESTAMP_MISSING}), 400
@@ -242,11 +244,12 @@ def reset_password():
if not token or not new_password: if not token or not new_password:
return jsonify({'error': 'Missing token or password'}), 400 return jsonify({'error': 'Missing token or password'}), 400
user = users_db.get(UserQuery.reset_token == token) user_dict = users_db.get(UserQuery.reset_token == token)
user = User.from_dict(user_dict) if user_dict else None
if not user: if not user:
return jsonify({'error': 'Invalid token', 'code': INVALID_TOKEN}), 400 return jsonify({'error': 'Invalid token', 'code': INVALID_TOKEN}), 400
created_str = user.get('reset_token_created') created_str = user.reset_token_created
if not created_str: if not created_str:
return jsonify({'error': 'Token timestamp missing', 'code': TOKEN_TIMESTAMP_MISSING}), 400 return jsonify({'error': 'Token timestamp missing', 'code': TOKEN_TIMESTAMP_MISSING}), 400
@@ -254,11 +257,9 @@ def reset_password():
if datetime.now(timezone.utc) - created_dt > timedelta(minutes=RESET_PASSWORD_TOKEN_EXPIRY_MINUTES): if datetime.now(timezone.utc) - created_dt > timedelta(minutes=RESET_PASSWORD_TOKEN_EXPIRY_MINUTES):
return jsonify({'error': 'Token expired', 'code': TOKEN_EXPIRED}), 400 return jsonify({'error': 'Token expired', 'code': TOKEN_EXPIRED}), 400
users_db.update({ user.password = new_password # Hash in production!
'password': new_password, # Hash in production! user.reset_token = None
'reset_token': None, user.reset_token_created = None
'reset_token_created': None users_db.update(user.to_dict(), UserQuery.email == user.email)
}, UserQuery.reset_token == token)
return jsonify({'message': 'Password has been reset'}), 200 return jsonify({'message': 'Password has been reset'}), 200

45
api/user_api.py Normal file
View File

@@ -0,0 +1,45 @@
from flask import Blueprint, request, jsonify, current_app
from models.user import User
from tinydb import Query
from db.db import users_db
import jwt
user_api = Blueprint('user_api', __name__)
UserQuery = Query()
def get_current_user():
token = request.cookies.get('token')
if not token:
return None
try:
payload = jwt.decode(token, current_app.config['SECRET_KEY'], algorithms=['HS256'])
email = payload.get('email')
user_dict = users_db.get(UserQuery.email == email)
return User.from_dict(user_dict) if user_dict else None
except Exception:
return None
@user_api.route('/user/profile', methods=['GET'])
def get_profile():
user = get_current_user()
if not user:
return jsonify({'error': 'Unauthorized'}), 401
return jsonify({
'first_name': user.first_name,
'last_name': user.last_name,
'email': user.email,
'image_id': user.image_id
}), 200
@user_api.route('/user/image', methods=['PUT'])
def update_image():
user = get_current_user()
if not user:
return jsonify({'error': 'Unauthorized'}), 401
data = request.get_json()
image_id = data.get('image_id')
if not image_id:
return jsonify({'error': 'Missing image_id'}), 400
user.image_id = image_id
users_db.update(user.to_dict(), UserQuery.email == user.email)
return jsonify({'message': 'Image updated', 'image_id': image_id}), 200

11
main.py
View File

@@ -1,17 +1,19 @@
import sys, logging, os import logging
from config.paths import get_user_image_dir import sys
from flask import Flask, request, jsonify from flask import Flask, request, jsonify
from flask_cors import CORS from flask_cors import CORS
from api.auth_api import auth_api, mail
from api.child_api import child_api from api.child_api import child_api
from api.image_api import image_api from api.image_api import image_api
from api.reward_api import reward_api from api.reward_api import reward_api
from api.task_api import task_api from api.task_api import task_api
from api.auth_api import auth_api, mail from api.user_api import user_api
from config.version import get_full_version from config.version import get_full_version
from db.default import initializeImages, createDefaultTasks, createDefaultRewards
from events.broadcaster import Broadcaster from events.broadcaster import Broadcaster
from events.sse import sse_response_for_user, send_to_user from events.sse import sse_response_for_user, send_to_user
from db.default import initializeImages, createDefaultTasks, createDefaultRewards
# Configure logging once at application startup # Configure logging once at application startup
logging.basicConfig( logging.basicConfig(
@@ -30,6 +32,7 @@ app.register_blueprint(reward_api)
app.register_blueprint(task_api) app.register_blueprint(task_api)
app.register_blueprint(image_api) app.register_blueprint(image_api)
app.register_blueprint(auth_api) app.register_blueprint(auth_api)
app.register_blueprint(user_api)
app.config.update( app.config.update(
MAIL_SERVER='smtp.gmail.com', MAIL_SERVER='smtp.gmail.com',

50
models/user.py Normal file
View File

@@ -0,0 +1,50 @@
from dataclasses import dataclass, field
from models.base import BaseModel
@dataclass
class User(BaseModel):
first_name: str
last_name: str
email: str
password: str # In production, this should be hashed
verified: bool = False
verify_token: str | None = None
verify_token_created: str | None = None
reset_token: str | None = None
reset_token_created: str | None = None
image_id: str | None = None
@classmethod
def from_dict(cls, d: dict):
return cls(
first_name=d.get('first_name'),
last_name=d.get('last_name'),
email=d.get('email'),
password=d.get('password'),
verified=d.get('verified', False),
verify_token=d.get('verify_token'),
verify_token_created=d.get('verify_token_created'),
reset_token=d.get('reset_token'),
reset_token_created=d.get('reset_token_created'),
image_id=d.get('image_id'),
id=d.get('id'),
created_at=d.get('created_at'),
updated_at=d.get('updated_at')
)
def to_dict(self):
base = super().to_dict()
base.update({
'first_name': self.first_name,
'last_name': self.last_name,
'email': self.email,
'password': self.password,
'verified': self.verified,
'verify_token': self.verify_token,
'verify_token_created': self.verify_token_created,
'reset_token': self.reset_token,
'reset_token_created': self.reset_token_created,
'image_id': self.image_id
})
return base

View File

@@ -93,10 +93,57 @@
font-weight: 600; font-weight: 600;
margin-left: 6px; margin-left: 6px;
} }
.btn-link.btn-disabled { .btn-link:disabled {
text-decoration: none; text-decoration: none;
opacity: 0.75; opacity: 0.75;
cursor: default; cursor: default;
pointer-events: none; pointer-events: none;
color: var(--btn-primary); color: var(--btn-primary);
} }
.round-btn {
background: var(--sign-in-btn-bg);
color: var(--sign-in-btn-color);
border: 2px solid var(--sign-in-btn-border);
border-radius: 6px;
font-size: 0.85rem;
font-weight: 600;
padding: 0.2rem 0.5rem;
margin-right: 0.1rem;
cursor: pointer;
transition:
background 0.18s,
color 0.18s;
}
.round-btn:hover {
background: var(--sign-in-btn-hover-bg);
color: var(--sign-in-btn-hover-color);
}
/* Floating Action Button (FAB) */
.fab {
position: fixed;
bottom: 2rem;
right: 2rem;
background: var(--fab-bg);
color: #fff;
border: none;
border-radius: 50%;
width: 56px;
height: 56px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
cursor: pointer;
font-size: 24px;
z-index: 1300;
}
.fab:hover {
background: var(--fab-hover-bg);
}
.fab:active {
background: var(--fab-active-bg);
}

View File

@@ -14,15 +14,6 @@
color: var(--child-list-title-color, #fff); color: var(--child-list-title-color, #fff);
} }
.loading,
.empty {
text-align: center;
padding: 1rem;
font-size: 0.9rem;
opacity: 0.8;
color: var(--child-list-loading-color, #fff);
}
.scroll-wrapper { .scroll-wrapper {
overflow-x: auto; overflow-x: auto;
overflow-y: hidden; overflow-y: hidden;

View File

@@ -0,0 +1,29 @@
.loading,
.empty {
text-align: center;
padding: 1rem;
font-size: 0.9rem;
opacity: 0.8;
color: var(--child-list-loading-color, #fff);
}
.error {
color: var(--error);
margin-top: 0.7rem;
text-align: center;
background: var(--error-bg);
border-radius: 8px;
padding: 1rem;
}
.error-message {
color: var(--error, #e53e3e);
font-size: 0.98rem;
margin-top: 0.4rem;
display: block;
}
.success-message {
color: var(--success, #16a34a);
font-size: 1rem;
}

View File

@@ -1,3 +1,4 @@
.profile-view,
.edit-view, .edit-view,
.child-edit-view, .child-edit-view,
.reward-edit-view, .reward-edit-view,
@@ -10,6 +11,7 @@
padding: 2rem 2.2rem 1.5rem 2.2rem; padding: 2rem 2.2rem 1.5rem 2.2rem;
} }
.profile-view h2,
.edit-view h2, .edit-view h2,
.child-edit-view h2, .child-edit-view h2,
.reward-edit-view h2, .reward-edit-view h2,
@@ -19,22 +21,38 @@
color: var(--form-heading); color: var(--form-heading);
} }
.form-group, .profile-form,
.reward-form label, .task-form,
.task-form label { .reward-form,
display: block; .child-edit-form {
margin-bottom: 1.1rem; display: flex;
font-weight: 500; flex-direction: column;
color: var(--form-label); gap: 1.1rem;
width: 100%;
} }
input[type='text'], .profile-form div.group,
input[type='number'], .task-form div.group,
.reward-form input[type='text'], .reward-form div.group,
.reward-form input[type='number'], .child-edit-form div.group {
.task-form input[type='text'], width: 100%;
.task-form input[type='number'] { display: flex;
flex-direction: column;
gap: 0.35rem;
box-sizing: border-box;
}
.profile-form div.group label,
.task-form div.group label,
.reward-form div.group label,
.child-edit-form div.group label {
font-weight: 600;
color: var(--form-label-color);
font-size: 1rem;
}
div.group input[type='text'],
div.group input[type='number'],
div.group input[type='email'] {
display: block; display: block;
width: 100%; width: 100%;
margin-top: 0.4rem; margin-top: 0.4rem;
@@ -45,9 +63,18 @@ input[type='number'],
background: var(--form-input-bg); background: var(--form-input-bg);
box-sizing: border-box; box-sizing: border-box;
} }
div.group input:focus {
outline: none;
border: 1.5px solid var(--form-input-focus);
}
.loading-message { .loading-message {
text-align: center; text-align: center;
color: var(--form-loading); color: var(--form-loading);
margin-bottom: 1.2rem; margin-bottom: 1.2rem;
} }
.form-group.image-picker-group {
display: block;
text-align: left;
}

View File

@@ -71,8 +71,8 @@
--fab-bg: #667eea; --fab-bg: #667eea;
--fab-hover-bg: #5a67d8; --fab-hover-bg: #5a67d8;
--fab-active-bg: #4c51bf; --fab-active-bg: #4c51bf;
--no-children-color: #fdfdfd; --message-block-color: #fdfdfd;
--sub-message-color: #5d719d; --sub-message-color: #c1d0f1;
--sign-in-btn-bg: #fff; --sign-in-btn-bg: #fff;
--sign-in-btn-color: #2563eb; --sign-in-btn-color: #2563eb;
--sign-in-btn-border: #2563eb; --sign-in-btn-border: #2563eb;

View File

@@ -14,57 +14,6 @@
border-radius: 12px; border-radius: 12px;
} }
/* List item */
.list-item {
display: flex;
align-items: center;
border: 2px outset var(--list-item-border-good);
border-radius: 8px;
padding: 0.2rem 1rem;
background: var(--list-item-bg);
font-size: 1.05rem;
font-weight: 500;
transition: border 0.18s;
margin-bottom: 0.2rem;
margin-left: 0.2rem;
margin-right: 0.2rem;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.03);
box-sizing: border-box;
}
.list-item.bad {
border-color: var(--list-item-border-bad);
background: var(--list-item-bg-bad);
}
.list-item.good {
border-color: var(--list-item-border-good);
background: var(--list-item-bg-good);
}
/* Image styles */
.list-image {
width: 36px;
height: 36px;
object-fit: cover;
border-radius: 8px;
margin-right: 0.7rem;
background: var(--list-image-bg);
flex-shrink: 0;
}
/* Name/label styles */
.list-name {
flex: 1;
text-align: left;
font-weight: 600;
}
/* Points/cost/requested text */
.list-value {
min-width: 60px;
text-align: right;
font-weight: 600;
}
/* Delete button */ /* Delete button */
.delete-btn { .delete-btn {
background: transparent; background: transparent;

View File

@@ -0,0 +1,25 @@
.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: var(--modal-bg);
color: var(--modal-text);
padding: 1.25rem;
border-radius: 10px;
width: 360px;
max-width: calc(100% - 32px);
box-shadow: var(--modal-shadow);
text-align: center;
}
.modal h3 {
margin-bottom: 0.5rem;
font-size: 1.05rem;
}

View File

@@ -0,0 +1,25 @@
.scroll-wrapper::-webkit-scrollbar {
height: 8px;
}
.scroll-wrapper::-webkit-scrollbar-track {
background: var(--child-list-scrollbar-track, rgba(255, 255, 255, 0.05));
border-radius: 10px;
}
.scroll-wrapper::-webkit-scrollbar-thumb {
background: var(
--child-list-scrollbar-thumb,
linear-gradient(180deg, rgba(102, 126, 234, 0.8), rgba(118, 75, 162, 0.8))
);
border-radius: 10px;
border: var(--child-list-scrollbar-thumb-border, 2px solid rgba(255, 255, 255, 0.08));
}
.scroll-wrapper::-webkit-scrollbar-thumb:hover {
background: var(
--child-list-scrollbar-thumb-hover,
linear-gradient(180deg, rgba(102, 126, 234, 1), rgba(118, 75, 162, 1))
);
box-shadow: 0 0 8px rgba(102, 126, 234, 0.4);
}

View File

@@ -94,21 +94,6 @@
font-size: 1rem; font-size: 1rem;
} }
.no-message {
margin: 2rem 0;
font-size: 1.15rem;
font-weight: 600;
text-align: center;
color: #fdfdfd;
line-height: 1.5;
}
.sub-message {
margin-top: 0.3rem;
font-size: 1rem;
font-weight: 400;
color: #b5ccff;
}
/* Floating Action Button (FAB) */ /* Floating Action Button (FAB) */
.fab { .fab {
position: fixed; position: fixed;

View File

@@ -2,16 +2,16 @@
<div class="child-edit-view"> <div class="child-edit-view">
<h2>{{ isEdit ? 'Edit Child' : 'Create Child' }}</h2> <h2>{{ isEdit ? 'Edit Child' : 'Create Child' }}</h2>
<div v-if="loading" class="loading-message">Loading child...</div> <div v-if="loading" class="loading-message">Loading child...</div>
<form v-else @submit.prevent="submit" class="form"> <form v-else @submit.prevent="submit" class="child-edit-form">
<div class="form-group"> <div class="group">
<label for="child-name">Name</label> <label for="child-name">Name</label>
<input id="child-name" ref="nameInput" v-model="name" required maxlength="64" /> <input type="text" id="child-name" ref="nameInput" v-model="name" required maxlength="64" />
</div> </div>
<div class="form-group"> <div class="group">
<label for="child-age">Age</label> <label for="child-age">Age</label>
<input id="child-age" v-model.number="age" type="number" min="0" max="120" required /> <input id="child-age" v-model.number="age" type="number" min="0" max="120" required />
</div> </div>
<div class="form-group image-picker-group"> <div class="group">
<label for="child-image">Image</label> <label for="child-image">Image</label>
<ImagePicker <ImagePicker
id="child-image" id="child-image"
@@ -154,44 +154,4 @@ function onCancel() {
} }
</script> </script>
<style scoped> <style scoped></style>
.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: var(--form-label-color);
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 var(--form-input-border);
font-size: 1rem;
background: var(--form-input-bg);
color: var(--form-input-color);
transition: border 0.2s;
}
input:focus {
outline: none;
border: 1.5px solid var(--form-input-focus);
}
.form-group.image-picker-group {
display: block;
text-align: left;
}
</style>

View File

@@ -0,0 +1,169 @@
<template>
<div class="profile-view">
<h2>User Profile</h2>
<form class="profile-form" @submit.prevent>
<div class="group">
<label for="child-image">Image</label>
<ImagePicker
id="child-image"
v-model="selectedImageId"
:image-type="1"
@add-image="onAddImage"
/>
</div>
<div class="group">
<label for="first-name">First Name</label>
<input id="first-name" v-model="firstName" type="text" disabled />
</div>
<div class="group">
<label for="last-name">Last Name</label>
<input id="last-name" v-model="lastName" type="text" disabled />
</div>
<div class="group">
<label for="email">Email Address</label>
<input id="email" v-model="email" type="email" disabled />
</div>
<div>
<button type="button" class="btn-link" @click="resetPassword" :disabled="resetting">
{{ resetting ? 'Sending...' : 'Reset Password' }}
</button>
</div>
<div v-if="successMsg" class="success-message" aria-live="polite">{{ successMsg }}</div>
<div v-if="errorMsg" class="error-message" aria-live="polite">{{ errorMsg }}</div>
</form>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import ImagePicker from '@/components/utils/ImagePicker.vue'
import { getCachedImageUrl } from '@/common/imageCache'
import '@/assets/edit-forms.css'
import '@/assets/actions-shared.css'
import '@/assets/button-shared.css'
const firstName = ref('')
const lastName = ref('')
const email = ref('')
const avatarId = ref<string | null>(null)
const avatarUrl = ref('/static/avatar-default.png')
const selectedImageId = ref<string | null>(null)
const localImageFile = ref<File | null>(null)
const errorMsg = ref('')
const successMsg = ref('')
const resetting = ref(false)
onMounted(async () => {
try {
const res = await fetch('/api/user/profile')
if (!res.ok) throw new Error('Failed to load profile')
const data = await res.json()
firstName.value = data.first_name || ''
lastName.value = data.last_name || ''
email.value = data.email || ''
avatarId.value = data.image_id || null
selectedImageId.value = data.image_id || null
// Use imageCache to get avatar URL
if (avatarId.value) {
avatarUrl.value = await getCachedImageUrl(avatarId.value)
} else {
avatarUrl.value = '/static/avatar-default.png'
}
} catch {
errorMsg.value = 'Could not load user profile.'
}
})
// Watch for avatarId changes (e.g., after updating avatar)
watch(avatarId, async (id) => {
if (id) {
avatarUrl.value = await getCachedImageUrl(id)
} else {
avatarUrl.value = '/static/avatar-default.png'
}
})
function onAddImage({ id, file }: { id: string; file: File }) {
if (id === 'local-upload') {
localImageFile.value = file
} else {
localImageFile.value = null
selectedImageId.value = id
updateAvatar(id)
}
}
async function updateAvatar(imageId: string) {
errorMsg.value = ''
successMsg.value = ''
try {
const res = await fetch('/api/user/avatar', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image_id: imageId }),
})
if (!res.ok) throw new Error('Failed to update avatar')
// Update avatarId, which will trigger the watcher to update avatarUrl
avatarId.value = imageId
successMsg.value = 'Avatar updated!'
} catch {
errorMsg.value = 'Failed to update avatar.'
}
}
// If uploading a new image file
watch(localImageFile, async (file) => {
if (!file) return
errorMsg.value = ''
successMsg.value = ''
const formData = new FormData()
formData.append('file', file)
formData.append('type', '2')
formData.append('permanent', 'true')
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()
selectedImageId.value = data.id
await updateAvatar(data.id)
} catch {
errorMsg.value = 'Failed to upload avatar image.'
}
})
async function resetPassword() {
resetting.value = true
errorMsg.value = ''
successMsg.value = ''
try {
const res = await fetch('/api/request-password-reset', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email.value }),
})
if (!res.ok) throw new Error('Failed to send reset email')
successMsg.value =
'If this email is registered, you will receive a password reset link shortly.'
} catch {
errorMsg.value = 'Failed to send password reset email.'
} finally {
resetting.value = false
}
}
</script>
<style scoped>
.success-message {
color: var(--success, #16a34a);
font-size: 1rem;
}
.error-message {
color: var(--error, #e53e3e);
font-size: 0.98rem;
margin-top: 0.4rem;
}
</style>

View File

@@ -3,19 +3,25 @@
<h2>{{ isEdit ? 'Edit Reward' : 'Create Reward' }}</h2> <h2>{{ isEdit ? 'Edit Reward' : 'Create Reward' }}</h2>
<div v-if="loading" class="loading-message">Loading reward...</div> <div v-if="loading" class="loading-message">Loading reward...</div>
<form v-else @submit.prevent="submit" class="reward-form"> <form v-else @submit.prevent="submit" class="reward-form">
<div class="group">
<label> <label>
Reward Name Reward Name
<input ref="nameInput" v-model="name" type="text" required maxlength="64" /> <input ref="nameInput" v-model="name" type="text" required maxlength="64" />
</label> </label>
</div>
<div class="group">
<label> <label>
Description Description
<input v-model="description" type="text" maxlength="128" /> <input v-model="description" type="text" maxlength="128" />
</label> </label>
</div>
<div class="group">
<label> <label>
Cost Cost
<input v-model.number="cost" type="number" min="1" max="1000" required /> <input v-model.number="cost" type="number" min="1" max="1000" required />
</label> </label>
<div class="form-group image-picker-group"> </div>
<div class="group">
<label for="reward-image">Image</label> <label for="reward-image">Image</label>
<ImagePicker <ImagePicker
id="reward-image" id="reward-image"

View File

@@ -11,6 +11,10 @@ import type {
ChildRewardTriggeredEventPayload, ChildRewardTriggeredEventPayload,
Event, Event,
} from '@/common/models' } from '@/common/models'
import MessageBlock from '@/components/shared/MessageBlock.vue'
import '@/assets/common.css'
import '@/assets/modal.css'
import '@/assets/button-shared.css'
const router = useRouter() const router = useRouter()
const children = ref<Child[]>([]) const children = ref<Child[]>([])
@@ -273,16 +277,13 @@ onBeforeUnmount(() => {
<template> <template>
<div> <div>
<div v-if="children.length === 0" class="no-message"> <MessageBlock v-if="children.length === 0" message="No children">
<div>No children</div> <span v-if="!isParentAuthenticated">
<div class="sub-message"> <button class="round-btn" @click="eventBus.emit('open-login')">Sign in</button> to create a
<template v-if="!isParentAuthenticated"> child
<button class="sign-in-btn" @click="eventBus.emit('open-login')">Sign in</button> to </span>
create a child <span v-else> <button class="round-btn" @click="createChild">Create</button> a child </span>
</template> </MessageBlock>
<span v-else><button class="sign-in-btn" @click="createChild">Create</button> a child</span>
</div>
</div>
<div v-else-if="loading" class="loading">Loading...</div> <div v-else-if="loading" class="loading">Loading...</div>
@@ -383,35 +384,6 @@ onBeforeUnmount(() => {
</template> </template>
<style scoped> <style scoped>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
.container {
display: flex;
flex-direction: column;
}
h1 {
color: white;
margin-bottom: 2rem;
font-size: 2.5rem;
text-align: center;
}
.loading,
.empty {
display: flex;
align-items: center;
justify-content: center;
min-height: 200px;
color: white;
font-size: 1.2rem;
text-align: center;
}
.grid { .grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
@@ -547,33 +519,6 @@ h1 {
background: var(--child-image-bg); background: var(--child-image-bg);
} }
/* 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: var(--modal-bg);
color: var(--modal-text);
padding: 1.25rem;
border-radius: 10px;
width: 360px;
max-width: calc(100% - 32px);
box-shadow: var(--modal-shadow);
text-align: center;
}
.modal h3 {
margin-bottom: 0.5rem;
font-size: 1.05rem;
}
.points { .points {
font-size: 1.05rem; font-size: 1.05rem;
color: var(--points-color); color: var(--points-color);
@@ -581,23 +526,4 @@ h1 {
font-weight: 600; font-weight: 600;
text-align: center; text-align: center;
} }
.sign-in-btn {
background: var(--sign-in-btn-bg);
color: var(--sign-in-btn-color);
border: 2px solid var(--sign-in-btn-border);
border-radius: 6px;
font-size: 0.85rem;
font-weight: 600;
padding: 0.2rem 0.5rem;
margin-right: 0.1rem;
cursor: pointer;
transition:
background 0.18s,
color 0.18s;
}
.sign-in-btn:hover {
background: var(--sign-in-btn-hover-bg);
color: var(--sign-in-btn-hover-color);
}
</style> </style>

View File

@@ -0,0 +1,163 @@
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
import { getCachedImageUrl } from '@/common/imageCache'
const props = defineProps<{
fetchUrl: string
itemKey: string
itemFields: string[]
imageField?: string
selectable?: boolean
deletable?: boolean
onEdit?: (id: string) => void
onDelete?: (id: string) => void
filterFn?: (item: any) => boolean
}>()
const emit = defineEmits(['edit', 'delete', 'loading-complete'])
const items = ref<any[]>([])
const loading = ref(true)
const error = ref<string | null>(null)
const selectedItems = ref<string[]>([])
const fetchItems = async () => {
loading.value = true
error.value = null
try {
const resp = await fetch(props.fetchUrl)
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
const data = await resp.json()
let itemList = data[props.itemKey] || []
if (props.filterFn) itemList = itemList.filter(props.filterFn)
await Promise.all(
itemList.map(async (item: any) => {
if (props.imageField && item[props.imageField]) {
try {
item.image_url = await getCachedImageUrl(item[props.imageField])
} catch {
item.image_url = null
}
}
}),
)
items.value = itemList
if (props.selectable) selectedItems.value = []
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to fetch items'
items.value = []
if (props.selectable) selectedItems.value = []
} finally {
emit('loading-complete', items.value.length)
loading.value = false
}
}
onMounted(fetchItems)
watch(() => props.fetchUrl, fetchItems)
const handleEdit = (id: string) => {
emit('edit', id)
props.onEdit?.(id)
}
const handleDelete = (id: string) => {
emit('delete', id)
props.onDelete?.(id)
}
</script>
<template>
<div class="listbox">
<div v-if="loading" class="loading">Loading...</div>
<div v-else-if="error" class="error">{{ error }}</div>
<div v-else-if="items.length === 0" class="empty">No items found.</div>
<div v-else>
<div v-for="(item, idx) in items" :key="item.id">
<div class="list-item" @click="handleEdit(item.id)">
<img v-if="item.image_url" :src="item.image_url" alt="Item" class="list-image" />
<span class="list-name">{{ item.name }}</span>
<span v-if="item.points !== undefined" class="list-points">{{ item.points }} pts</span>
<span v-if="item.cost !== undefined" class="list-cost">{{ item.cost }} pts</span>
<input
v-if="props.selectable"
type="checkbox"
class="list-checkbox"
v-model="selectedItems"
:value="item.id"
@click.stop
/>
<button
v-if="props.deletable"
class="delete-btn"
@click.stop="handleDelete(item.id)"
aria-label="Delete item"
type="button"
>
<!-- SVG icon here -->
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
<circle cx="10" cy="10" r="9" fill="#fff" stroke="#ef4444" stroke-width="2" />
<path
d="M7 7l6 6M13 7l-6 6"
stroke="#ef4444"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
</button>
</div>
<div v-if="idx < items.length - 1" class="list-separator"></div>
</div>
</div>
</div>
</template>
<style scoped>
.list-item {
display: flex;
align-items: center;
border: 2px outset var(--list-item-border-good);
border-radius: 8px;
padding: 0.2rem 1rem;
background: var(--list-item-bg);
font-size: 1.05rem;
font-weight: 500;
transition: border 0.18s;
margin-bottom: 0.2rem;
margin-left: 0.2rem;
margin-right: 0.2rem;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.03);
box-sizing: border-box;
}
.list-item.bad {
border-color: var(--list-item-border-bad);
background: var(--list-item-bg-bad);
}
.list-item.good {
border-color: var(--list-item-border-good);
background: var(--list-item-bg-good);
}
/* Image styles */
.list-image {
width: 36px;
height: 36px;
object-fit: cover;
border-radius: 8px;
margin-right: 0.7rem;
background: var(--list-image-bg);
flex-shrink: 0;
}
/* Name/label styles */
.list-name {
flex: 1;
text-align: left;
font-weight: 600;
}
/* Points/cost/requested text */
.list-value {
min-width: 60px;
text-align: right;
font-weight: 600;
}
</style>

View File

@@ -3,6 +3,8 @@ import { ref, nextTick, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { eventBus } from '@/common/eventBus' import { eventBus } from '@/common/eventBus'
import { authenticateParent, isParentAuthenticated, logoutParent } from '../../stores/auth' import { authenticateParent, isParentAuthenticated, logoutParent } from '../../stores/auth'
import '@/assets/modal.css'
import '@/assets/actions-shared.css'
const router = useRouter() const router = useRouter()
const show = ref(false) const show = ref(false)
@@ -62,6 +64,10 @@ async function signOut() {
dropdownOpen.value = false dropdownOpen.value = false
} }
function goToProfile() {
router.push('/parent/profile')
}
onMounted(() => { onMounted(() => {
eventBus.on('open-login', open) eventBus.on('open-login', open)
}) })
@@ -71,7 +77,7 @@ onUnmounted(() => {
</script> </script>
<template> <template>
<div class="login-button-root" style="position: relative"> <div style="position: relative">
<button v-if="!isParentAuthenticated" @click="open" aria-label="Parent login" class="login-btn"> <button v-if="!isParentAuthenticated" @click="open" aria-label="Parent login" class="login-btn">
Parent Parent
</button> </button>
@@ -99,6 +105,9 @@ onUnmounted(() => {
z-index: 10; z-index: 10;
" "
> >
<button class="menu-item" @click="goToProfile" style="width: 100%; text-align: left">
Profile
</button>
<button class="menu-item" @click="handleLogout" style="width: 100%; text-align: left"> <button class="menu-item" @click="handleLogout" style="width: 100%; text-align: left">
Log out Log out
</button> </button>
@@ -135,11 +144,6 @@ onUnmounted(() => {
<style> <style>
/* modal */ /* modal */
.modal h3 {
margin-bottom: 0.5rem;
font-size: 1.05rem;
}
.pin-input { .pin-input {
width: 100%; width: 100%;
padding: 0.5rem 0.6rem; padding: 0.5rem 0.6rem;
@@ -151,16 +155,6 @@ onUnmounted(() => {
text-align: center; text-align: center;
} }
.actions {
display: flex;
gap: 0.5rem;
justify-content: center;
margin-bottom: 0.4rem;
}
.login-button-root {
}
.dropdown-menu { .dropdown-menu {
padding: 0.5rem 0; padding: 0.5rem 0;
} }
@@ -183,4 +177,14 @@ onUnmounted(() => {
.menu-item.danger { .menu-item.danger {
color: var(--menu-item-danger, #ff4d4f); color: var(--menu-item-danger, #ff4d4f);
} }
@media (max-width: 600px) {
.menu-item {
padding: 0.85rem 0.7rem;
font-size: 1rem;
}
.menu-item + .menu-item {
margin-top: 0.35rem;
}
}
</style> </style>

View File

@@ -0,0 +1,28 @@
<template>
<div class="message-block">
<div>{{ message }}</div>
<div class="sub-message">
<slot />
</div>
</div>
</template>
<script setup lang="ts">
import '@/assets/global.css'
defineProps<{ message: string }>()
</script>
<style scoped>
.message-block {
margin: 2rem 0;
font-size: 1.15rem;
font-weight: 600;
text-align: center;
color: var(--message-block-color);
line-height: 1.5;
}
.sub-message {
margin-top: 0.3rem;
font-size: 1rem;
font-weight: 400;
color: var(--sub-message-color);
}
</style>

View File

@@ -139,17 +139,36 @@ function onAddImage({ id, file }: { id: string; file: File }) {
<h2>{{ isEdit ? 'Edit Task' : 'Create Task' }}</h2> <h2>{{ isEdit ? 'Edit Task' : 'Create Task' }}</h2>
<div v-if="loading" class="loading-message">Loading task...</div> <div v-if="loading" class="loading-message">Loading task...</div>
<form v-else @submit.prevent="submit" class="task-form"> <form v-else @submit.prevent="submit" class="task-form">
<label> <div class="group">
<label for="task-name">
Task Name Task Name
<input ref="nameInput" v-model="name" type="text" required maxlength="64" /> <input
id="task-name"
ref="nameInput"
v-model="name"
type="text"
required
maxlength="64"
/>
</label> </label>
<label> </div>
<div class="group">
<label for="task-points">
Task Points Task Points
<input v-model.number="points" type="number" min="1" max="100" required /> <input
id="task-points"
v-model.number="points"
type="number"
min="1"
max="100"
required
/>
</label> </label>
<label> </div>
<div class="group">
<label for="task-type">
Task Type Task Type
<div class="good-bad-toggle"> <div class="good-bad-toggle" id="task-type">
<button <button
type="button" type="button"
:class="['toggle-btn', isGood ? 'good-active' : '']" :class="['toggle-btn', isGood ? 'good-active' : '']"
@@ -166,7 +185,9 @@ function onAddImage({ id, file }: { id: string; file: File }) {
</button> </button>
</div> </div>
</label> </label>
<div class="form-group image-picker-group"> </div>
<div class="group">
<label for="task-image">Image</label>
<ImagePicker <ImagePicker
id="task-image" id="task-image"
v-model="selectedImageId" v-model="selectedImageId"

View File

@@ -7,12 +7,16 @@
</div> </div>
</div> </div>
<TaskList <ItemList
v-else v-else
ref="taskListRef" fetchUrl="/api/task/list"
:deletable="true" itemKey="tasks"
@edit-task="(taskId) => $router.push({ name: 'EditTask', params: { id: taskId } })" :itemFields="['id', 'name', 'points', 'is_good', 'image_id']"
@delete-task="confirmDeleteTask" imageField="image_id"
selectable
deletable
@edit="(taskId) => $router.push({ name: 'EditTask', params: { id: taskId } })"
@delete="confirmDeleteTask"
@loading-complete="(count) => (taskCountRef = count)" @loading-complete="(count) => (taskCountRef = count)"
/> />
@@ -40,6 +44,7 @@
import { ref } from 'vue' import { ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import TaskList from './TaskList.vue' import TaskList from './TaskList.vue'
import ItemList from '../shared/ItemList.vue'
const $router = useRouter() const $router = useRouter()

View File

@@ -203,7 +203,7 @@ function updateLocalImage(url: string, file: File) {
</script> </script>
<template> <template>
<div> <div class="picker">
<div class="image-scroll"> <div class="image-scroll">
<div v-if="loadingImages" class="loading-images">Loading images...</div> <div v-if="loadingImages" class="loading-images">Loading images...</div>
<div v-else class="image-list"> <div v-else class="image-list">
@@ -268,6 +268,13 @@ function updateLocalImage(url: string, file: File) {
</template> </template>
<style scoped> <style scoped>
.picker {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
}
.image-scroll { .image-scroll {
width: 100%; width: 100%;
margin: 0.7rem 0 0.2rem 0; margin: 0.7rem 0 0.2rem 0;

View File

@@ -152,6 +152,11 @@ const routes = [
component: NotificationView, component: NotificationView,
props: false, props: false,
}, },
{
path: 'profile',
name: 'UserProfile',
component: () => import('@/components/profile/UserProfile.vue'),
},
], ],
}, },
{ {