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 secrets, jwt
from datetime import datetime, timedelta, timezone
from models.user import User
from flask import Blueprint, request, jsonify, current_app
from flask_mail import Mail, Message
from tinydb import Query
@@ -57,15 +57,17 @@ def signup():
token = secrets.token_urlsafe(32)
now_iso = datetime.utcnow().isoformat()
users_db.insert({
'first_name': data['first_name'],
'last_name': data['last_name'],
'email': data['email'],
'password': data['password'], # Hash in production!
'verified': False,
'verify_token': token,
'verify_token_created': now_iso
})
user = User(
first_name=data['first_name'],
last_name=data['last_name'],
email=data['email'],
password=data['password'], # Hash in production!
verified=False,
verify_token=token,
verify_token_created=now_iso,
image_id="boy01"
)
users_db.insert(user.to_dict())
send_verification_email(data['email'], token)
return jsonify({'message': 'User created, verification email sent'}), 201
@@ -75,6 +77,7 @@ def verify():
status = 'success'
reason = ''
code = ''
user_dict = None
user = None
if not token:
@@ -82,13 +85,14 @@ def verify():
reason = 'Missing token'
code = MISSING_TOKEN
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:
status = 'error'
reason = 'Invalid token'
code = INVALID_TOKEN
else:
created_str = user.get('verify_token_created')
created_str = user.verify_token_created
if not created_str:
status = 'error'
reason = 'Token timestamp missing'
@@ -100,14 +104,17 @@ def verify():
reason = 'Token expired'
code = TOKEN_EXPIRED
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
if http_status == 200 and user is not None: ##user is verified, create the user's image directory
if 'email' not in user:
if http_status == 200 and user is not None:
if not user.email:
logger.error("Verified user has no email field.")
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)
return jsonify({'status': status, 'reason': reason, 'code': code}), http_status
@@ -119,23 +126,22 @@ def resend_verify():
if not email:
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:
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
token = secrets.token_urlsafe(32)
now_iso = datetime.utcnow().isoformat()
users_db.update({
'verify_token': token,
'verify_token_created': now_iso
}, UserQuery.email == email)
user.verify_token = token
user.verify_token_created = now_iso
users_db.update(user.to_dict(), UserQuery.email == email)
send_verification_email(email, token)
return jsonify({'message': 'Verification email resent'}), 200
@auth_api.route('/login', methods=['POST'])
def login():
data = request.get_json()
@@ -144,11 +150,12 @@ def login():
if not email or not password:
return jsonify({'error': 'Missing email or password', 'code': MISSING_EMAIL_OR_PASSWORD}), 400
user = users_db.get(UserQuery.email == email)
if not user or user.get('password') != password:
user_dict = users_db.get(UserQuery.email == email)
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
if not user.get('verified'):
if not user.verified:
return jsonify({'error': 'This account has not verified', 'code': NOT_VERIFIED}), 403
payload = {
@@ -170,45 +177,39 @@ def me():
try:
payload = jwt.decode(token, current_app.config['SECRET_KEY'], algorithms=['HS256'])
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:
return jsonify({'error': 'User not found', 'code': USER_NOT_FOUND}), 404
return jsonify({
'email': user['email'],
'id': sanitize_email(user['email']),
'first_name': user['first_name'],
'last_name': user['last_name'],
'verified': user['verified']
'email': user.email,
'id': sanitize_email(user.email),
'first_name': user.first_name,
'last_name': user.last_name,
'verified': user.verified
}), 200
except jwt.ExpiredSignatureError:
return jsonify({'error': 'Token expired', 'code': TOKEN_EXPIRED}), 401
except jwt.InvalidTokenError:
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'])
def request_password_reset():
data = request.get_json()
email = data.get('email')
# Always return success for privacy
success_msg = 'If this email is registered, you will receive a password reset link shortly.'
if not email:
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:
token = secrets.token_urlsafe(32)
now_iso = datetime.utcnow().isoformat()
users_db.update({
'reset_token': token,
'reset_token_created': now_iso
}, UserQuery.email == email)
user.reset_token = token
user.reset_token_created = now_iso
users_db.update(user.to_dict(), UserQuery.email == email)
send_reset_password_email(email, token)
return jsonify({'message': success_msg}), 200
@@ -219,11 +220,12 @@ def validate_reset_token():
if not token:
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:
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:
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:
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:
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:
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):
return jsonify({'error': 'Token expired', 'code': TOKEN_EXPIRED}), 400
users_db.update({
'password': new_password, # Hash in production!
'reset_token': None,
'reset_token_created': None
}, UserQuery.reset_token == token)
user.password = new_password # Hash in production!
user.reset_token = None
user.reset_token_created = None
users_db.update(user.to_dict(), UserQuery.email == user.email)
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
from config.paths import get_user_image_dir
import logging
import sys
from flask import Flask, request, jsonify
from flask_cors import CORS
from api.auth_api import auth_api, mail
from api.child_api import child_api
from api.image_api import image_api
from api.reward_api import reward_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 db.default import initializeImages, createDefaultTasks, createDefaultRewards
from events.broadcaster import Broadcaster
from events.sse import sse_response_for_user, send_to_user
from db.default import initializeImages, createDefaultTasks, createDefaultRewards
# Configure logging once at application startup
logging.basicConfig(
@@ -30,6 +32,7 @@ app.register_blueprint(reward_api)
app.register_blueprint(task_api)
app.register_blueprint(image_api)
app.register_blueprint(auth_api)
app.register_blueprint(user_api)
app.config.update(
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;
margin-left: 6px;
}
.btn-link.btn-disabled {
.btn-link:disabled {
text-decoration: none;
opacity: 0.75;
cursor: default;
pointer-events: none;
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);
}
.loading,
.empty {
text-align: center;
padding: 1rem;
font-size: 0.9rem;
opacity: 0.8;
color: var(--child-list-loading-color, #fff);
}
.scroll-wrapper {
overflow-x: auto;
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,
.child-edit-view,
.reward-edit-view,
@@ -10,6 +11,7 @@
padding: 2rem 2.2rem 1.5rem 2.2rem;
}
.profile-view h2,
.edit-view h2,
.child-edit-view h2,
.reward-edit-view h2,
@@ -19,22 +21,38 @@
color: var(--form-heading);
}
.form-group,
.reward-form label,
.task-form label {
display: block;
margin-bottom: 1.1rem;
font-weight: 500;
color: var(--form-label);
width: 100%;
.profile-form,
.task-form,
.reward-form,
.child-edit-form {
display: flex;
flex-direction: column;
gap: 1.1rem;
}
input[type='text'],
input[type='number'],
.reward-form input[type='text'],
.reward-form input[type='number'],
.task-form input[type='text'],
.task-form input[type='number'] {
.profile-form div.group,
.task-form div.group,
.reward-form div.group,
.child-edit-form div.group {
width: 100%;
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;
width: 100%;
margin-top: 0.4rem;
@@ -45,9 +63,18 @@ input[type='number'],
background: var(--form-input-bg);
box-sizing: border-box;
}
div.group input:focus {
outline: none;
border: 1.5px solid var(--form-input-focus);
}
.loading-message {
text-align: center;
color: var(--form-loading);
margin-bottom: 1.2rem;
}
.form-group.image-picker-group {
display: block;
text-align: left;
}

View File

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

View File

@@ -14,57 +14,6 @@
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-btn {
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;
}
.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) */
.fab {
position: fixed;

View File

@@ -2,16 +2,16 @@
<div class="child-edit-view">
<h2>{{ isEdit ? 'Edit Child' : 'Create Child' }}</h2>
<div v-if="loading" class="loading-message">Loading child...</div>
<form v-else @submit.prevent="submit" class="form">
<div class="form-group">
<form v-else @submit.prevent="submit" class="child-edit-form">
<div class="group">
<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 class="form-group">
<div class="group">
<label for="child-age">Age</label>
<input id="child-age" v-model.number="age" type="number" min="0" max="120" required />
</div>
<div class="form-group image-picker-group">
<div class="group">
<label for="child-image">Image</label>
<ImagePicker
id="child-image"
@@ -154,44 +154,4 @@ function onCancel() {
}
</script>
<style scoped>
.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>
<style scoped></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>
<div v-if="loading" class="loading-message">Loading reward...</div>
<form v-else @submit.prevent="submit" class="reward-form">
<label>
Reward Name
<input ref="nameInput" v-model="name" type="text" required maxlength="64" />
</label>
<label>
Description
<input v-model="description" type="text" maxlength="128" />
</label>
<label>
Cost
<input v-model.number="cost" type="number" min="1" max="1000" required />
</label>
<div class="form-group image-picker-group">
<div class="group">
<label>
Reward Name
<input ref="nameInput" v-model="name" type="text" required maxlength="64" />
</label>
</div>
<div class="group">
<label>
Description
<input v-model="description" type="text" maxlength="128" />
</label>
</div>
<div class="group">
<label>
Cost
<input v-model.number="cost" type="number" min="1" max="1000" required />
</label>
</div>
<div class="group">
<label for="reward-image">Image</label>
<ImagePicker
id="reward-image"

View File

@@ -11,6 +11,10 @@ import type {
ChildRewardTriggeredEventPayload,
Event,
} 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 children = ref<Child[]>([])
@@ -273,16 +277,13 @@ onBeforeUnmount(() => {
<template>
<div>
<div v-if="children.length === 0" class="no-message">
<div>No children</div>
<div class="sub-message">
<template v-if="!isParentAuthenticated">
<button class="sign-in-btn" @click="eventBus.emit('open-login')">Sign in</button> to
create a child
</template>
<span v-else><button class="sign-in-btn" @click="createChild">Create</button> a child</span>
</div>
</div>
<MessageBlock v-if="children.length === 0" message="No children">
<span v-if="!isParentAuthenticated">
<button class="round-btn" @click="eventBus.emit('open-login')">Sign in</button> to create a
child
</span>
<span v-else> <button class="round-btn" @click="createChild">Create</button> a child </span>
</MessageBlock>
<div v-else-if="loading" class="loading">Loading...</div>
@@ -383,35 +384,6 @@ onBeforeUnmount(() => {
</template>
<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 {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
@@ -547,33 +519,6 @@ h1 {
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 {
font-size: 1.05rem;
color: var(--points-color);
@@ -581,23 +526,4 @@ h1 {
font-weight: 600;
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>

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 { eventBus } from '@/common/eventBus'
import { authenticateParent, isParentAuthenticated, logoutParent } from '../../stores/auth'
import '@/assets/modal.css'
import '@/assets/actions-shared.css'
const router = useRouter()
const show = ref(false)
@@ -62,6 +64,10 @@ async function signOut() {
dropdownOpen.value = false
}
function goToProfile() {
router.push('/parent/profile')
}
onMounted(() => {
eventBus.on('open-login', open)
})
@@ -71,7 +77,7 @@ onUnmounted(() => {
</script>
<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">
Parent
</button>
@@ -99,6 +105,9 @@ onUnmounted(() => {
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">
Log out
</button>
@@ -135,11 +144,6 @@ onUnmounted(() => {
<style>
/* modal */
.modal h3 {
margin-bottom: 0.5rem;
font-size: 1.05rem;
}
.pin-input {
width: 100%;
padding: 0.5rem 0.6rem;
@@ -151,16 +155,6 @@ onUnmounted(() => {
text-align: center;
}
.actions {
display: flex;
gap: 0.5rem;
justify-content: center;
margin-bottom: 0.4rem;
}
.login-button-root {
}
.dropdown-menu {
padding: 0.5rem 0;
}
@@ -183,4 +177,14 @@ onUnmounted(() => {
.menu-item.danger {
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>

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

View File

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

View File

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

View File

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