refactoring
All checks were successful
Gitea Actions Demo / build-and-push (push) Successful in 13s

This commit is contained in:
2026-01-14 14:42:54 -05:00
parent c7c3cce76d
commit dcac2742e9
20 changed files with 366 additions and 800 deletions

View File

@@ -1,3 +1,5 @@
from time import sleep
from flask import Blueprint, request, jsonify
from tinydb import Query
@@ -137,6 +139,12 @@ def assign_task_to_child(id):
def set_child_tasks(id):
data = request.get_json() or {}
task_ids = data.get('task_ids')
if 'type' not in data:
return jsonify({'error': 'type is required (good or bad)'}), 400
task_type = data.get('type', 'good')
if task_type not in ['good', 'bad']:
return jsonify({'error': 'type must be either good or bad'}), 400
is_good = task_type == 'good'
if not isinstance(task_ids, list):
return jsonify({'error': 'task_ids must be a list'}), 400
@@ -147,22 +155,25 @@ def set_child_tasks(id):
result = child_db.search(ChildQuery.id == id)
if not result:
return jsonify({'error': 'Child not found'}), 404
child = Child.from_dict(result[0])
new_task_ids = set(task_ids)
# Optional: validate task IDs exist in the task DB
TaskQuery = Query()
valid_task_ids = []
for tid in new_task_ids:
if task_db.get(TaskQuery.id == tid):
valid_task_ids.append(tid)
# Add all existing child tasks of the opposite type
for task in task_db.all():
if task['id'] in child.tasks and task['is_good'] != is_good:
new_task_ids.add(task['id'])
# Convert back to list if needed
new_tasks = list(new_task_ids)
# Replace tasks with validated IDs
child_db.update({'tasks': valid_task_ids}, ChildQuery.id == id)
resp = send_event_for_current_user(Event(EventType.CHILD_TASKS_SET.value, ChildTasksSet(id, valid_task_ids)))
child_db.update({'tasks': new_tasks}, ChildQuery.id == id)
resp = send_event_for_current_user(Event(EventType.CHILD_TASKS_SET.value, ChildTasksSet(id, new_tasks)))
if resp:
return resp
return jsonify({
'message': f'Tasks set for child {id}.',
'task_ids': valid_task_ids,
'count': len(valid_task_ids)
'task_ids': new_tasks,
'count': len(new_tasks)
}), 200
@@ -242,6 +253,10 @@ def list_all_tasks(id):
result = child_db.search(ChildQuery.id == id)
if not result:
return jsonify({'error': 'Child not found'}), 404
has_type = "type" in request.args
if has_type and request.args.get('type') not in ['good', 'bad']:
return jsonify({'error': 'type must be either good or bad'}), 400
good = request.args.get('type', False) == 'good'
child = result[0]
assigned_ids = set(child.get('tasks', []))
@@ -249,8 +264,7 @@ def list_all_tasks(id):
# Get all tasks from database
all_tasks = task_db.all()
assigned_tasks = []
assignable_tasks = []
tasks = []
for task in all_tasks:
if not task or not task.get('id'):
@@ -263,18 +277,15 @@ def list_all_tasks(id):
task.get('image_id'),
task.get('id')
)
task_dict = ct.to_dict()
if has_type and task.get('is_good') != good:
continue
if task.get('id') in assigned_ids:
assigned_tasks.append(ct.to_dict())
else:
assignable_tasks.append(ct.to_dict())
task_dict.update({'assigned': task.get('id') in assigned_ids})
tasks.append(task_dict)
tasks.sort(key=lambda t: (not t['assigned'], t['name'].lower()))
return jsonify({
'assigned_tasks': assigned_tasks,
'assignable_tasks': assignable_tasks,
'assigned_count': len(assigned_tasks),
'assignable_count': len(assignable_tasks)
}), 200
return jsonify({ 'tasks': tasks }), 200
@child_api.route('/child/<id>/trigger-task', methods=['POST'])
@@ -341,9 +352,7 @@ def list_all_rewards(id):
# Get all rewards from database
all_rewards = reward_db.all()
assigned_rewards = []
assignable_rewards = []
rewards = []
for reward in all_rewards:
if not reward or not reward.get('id'):
@@ -356,16 +365,15 @@ def list_all_rewards(id):
reward.get('id')
)
if reward.get('id') in assigned_ids:
assigned_rewards.append(cr.to_dict())
else:
assignable_rewards.append(cr.to_dict())
reward_dict = cr.to_dict()
reward_dict.update({'assigned': reward.get('id') in assigned_ids})
rewards.append(reward_dict)
rewards.sort(key=lambda t: (not t['assigned'], t['name'].lower()))
return jsonify({
'assigned_rewards': assigned_rewards,
'assignable_rewards': assignable_rewards,
'assigned_count': len(assigned_rewards),
'assignable_count': len(assignable_rewards)
'rewards': rewards,
'rewards_count': len(rewards)
}), 200

View File

@@ -119,31 +119,3 @@
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

@@ -34,10 +34,12 @@
--list-bg: #fff5;
--list-item-bg: #f8fafc;
--list-item-border-good: #38c172;
--list-item-border-reward: #38c172;
--list-item-border-bad: #e53e3e;
--list-item-bg-good: #f0fff4;
--list-item-bg-bad: #fff5f5;
--list-item-border-good: #00e1ff;
--list-item-bg-reward: #94ffb1;
--list-item-bg-bad: #ffc5c5;
--list-item-bg-good: #8dabfd;
--list-image-bg: #eee;
--delete-btn-hover-bg: #ffeaea;
--delete-btn-hover-shadow: #ef444422;

View File

@@ -1,69 +0,0 @@
/* List container */
.listbox {
flex: 1 1 auto;
max-width: 480px;
width: 100%;
max-height: calc(100vh - 4.5rem);
overflow-y: auto;
margin: 0.2rem 0 0 0;
display: flex;
flex-direction: column;
gap: 0.7rem;
background: var(--list-bg);
padding: 0.2rem 0.2rem 0.2rem;
border-radius: 12px;
}
/* Delete button */
.delete-btn {
background: transparent;
border: none;
border-radius: 50%;
padding: 0.15rem;
margin-left: 0.7rem;
cursor: pointer;
display: flex;
align-items: center;
transition:
background 0.15s,
box-shadow 0.15s;
width: 2rem;
height: 2rem;
opacity: 0.92;
}
.delete-btn:hover {
background: var(--delete-btn-hover-bg);
box-shadow: 0 0 0 2px var(--delete-btn-hover-shadow);
opacity: 1;
}
.delete-btn svg {
display: block;
}
/* Checkbox */
.list-checkbox {
margin-left: 1rem;
width: 1.2em;
height: 1.2em;
accent-color: var(--checkbox-accent);
cursor: pointer;
}
/* Loading, error, empty states */
.loading,
.empty {
margin: 1.2rem 0;
color: var(--list-loading-color);
font-size: 1.15rem;
font-weight: 600;
text-align: center;
line-height: 1.5;
}
/* Separator (if needed) */
.list-separator {
height: 0px;
background: #0000;
margin: 0rem 0.2rem;
border-radius: 0px;
}

View File

@@ -94,34 +94,6 @@
font-size: 1rem;
}
/* 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);
}
/* Responsive Adjustments */
@media (max-width: 900px) {
.layout {

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { defineProps, toRefs, ref, watch, onBeforeUnmount } from 'vue'
import { toRefs, ref, watch, onBeforeUnmount } from 'vue'
import { getCachedImageUrl, revokeAllImageUrls } from '../../common/imageCache'
interface Child {

View File

@@ -370,7 +370,7 @@ const childId = computed(() => child.value?.id ?? null)
<div class="assign-buttons">
<button v-if="child" class="btn btn-primary" @click="goToAssignTasks">Assign Tasks</button>
<button v-if="child" class="btn btn-danger" @click="goToAssignBadHabits">
Assign Bad Habits
Assign Penalties
</button>
<button v-if="child" class="btn btn-green" @click="goToAssignRewards">Assign Rewards</button>
</div>

View File

@@ -2,21 +2,19 @@
<div class="reward-assign-view">
<h2>Assign Rewards</h2>
<div class="reward-view">
<div v-if="rewardCountRef == 0" class="no-rewards-message">
<div>No rewards available</div>
<div class="sub-message">
<button class="create-btn" @click="goToCreateReward">Create</button> a reward
</div>
</div>
<div class="reward-list-scroll">
<RewardList
v-if="rewardCountRef != 0"
ref="rewardListRef"
:child-id="childId"
:selectable="true"
@loading-complete="(count) => (rewardCountRef = count)"
/>
</div>
<MessageBlock v-if="rewardCountRef === 0" message="No rewards">
<span> <button class="round-btn" @click="goToCreateReward">Create</button> a reward </span>
</MessageBlock>
<ItemList
v-else
ref="rewardListRef"
:fetchUrl="`/api/child/${childId}/list-all-rewards`"
itemKey="rewards"
:itemFields="['id', 'name', 'cost', 'image_id']"
imageField="image_id"
selectable
@loading-complete="(count) => (rewardCountRef = count)"
/>
</div>
<div class="actions" v-if="rewardCountRef != 0">
<button class="btn btn-secondary" @click="onCancel">Cancel</button>
@@ -28,7 +26,9 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import RewardList from '../reward/RewardList.vue'
import ItemList from '../shared/ItemList.vue'
import MessageBlock from '../shared/MessageBlock.vue'
import '@/assets/actions-shared.css'
const route = useRoute()
const router = useRouter()
@@ -42,7 +42,7 @@ function goToCreateReward() {
}
async function onSubmit() {
const selectedIds = rewardListRef.value?.selectedRewards ?? []
const selectedIds = rewardListRef.value?.selectedItems ?? []
try {
const resp = await fetch(`/api/child/${childId}/set-rewards`, {
method: 'PUT',
@@ -72,23 +72,13 @@ function onCancel() {
padding: 0;
min-height: 0;
}
h2 {
.reward-assign-view h2 {
font-size: 1.15rem;
color: var(--assign-heading-color);
font-weight: 700;
text-align: center;
margin: 0.2rem;
}
.reward-list-scroll {
display: flex;
flex-direction: row;
width: 100%;
max-width: 480px;
justify-content: center;
min-height: 0;
overflow-y: auto;
margin-bottom: 2rem;
}
.reward-view {
display: flex;
@@ -100,37 +90,4 @@ h2 {
padding: 0;
min-height: 0;
}
.no-rewards-message {
margin: 2rem 0;
font-size: 1.15rem;
font-weight: 600;
text-align: center;
color: var(--assign-no-items-color);
line-height: 1.5;
}
.sub-message {
margin-top: 0.3rem;
font-size: 1rem;
font-weight: 400;
color: var(--assign-sub-message-color);
}
.create-btn {
background: var(--assign-create-btn-bg);
color: var(--assign-create-btn-color);
border: 2px solid var(--assign-create-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;
}
.create-btn:hover {
background: var(--assign-create-btn-hover-bg);
color: var(--assign-create-btn-hover-color);
}
</style>

View File

@@ -2,22 +2,19 @@
<div class="task-assign-view">
<h2>Assign Tasks</h2>
<div class="task-view">
<div v-if="taskCountRef == 0" class="no-tasks-message">
<div>No tasks available</div>
<div class="sub-message">
<button class="create-btn" @click="goToCreateTask">Create</button> a task
</div>
</div>
<div class="task-list-scroll">
<TaskList
v-if="taskCountRef != 0"
ref="taskListRef"
:child-id="childId"
:selectable="true"
:type-filter="typeFilter"
@loading-complete="(count) => (taskCountRef = count)"
/>
</div>
<MessageBlock v-if="taskCountRef === 0" message="No tasks">
<span> <button class="round-btn" @click="goToCreateTask">Create</button> a task </span>
</MessageBlock>
<ItemList
v-else
ref="taskListRef"
:fetchUrl="`/api/child/${childId}/list-all-tasks?type=${typeFilter}`"
itemKey="tasks"
:itemFields="['id', 'name', 'points', 'is_good', 'image_id']"
imageField="image_id"
selectable
@loading-complete="(count) => (taskCountRef = count)"
/>
</div>
<div class="actions" v-if="taskCountRef > 0">
<button class="btn btn-secondary" @click="onCancel">Cancel</button>
@@ -29,7 +26,9 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import TaskList from '../task/TaskList.vue'
import ItemList from '../shared/ItemList.vue'
import MessageBlock from '../shared/MessageBlock.vue'
import '@/assets/actions-shared.css'
const route = useRoute()
const router = useRouter()
@@ -49,12 +48,12 @@ function goToCreateTask() {
}
async function onSubmit() {
const selectedIds = taskListRef.value?.selectedTasks ?? []
const selectedIds = taskListRef.value?.selectedItems ?? []
try {
const resp = await fetch(`/api/child/${childId}/set-tasks`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ task_ids: selectedIds }),
body: JSON.stringify({ type: typeFilter.value, task_ids: selectedIds }),
})
if (!resp.ok) throw new Error('Failed to update tasks')
router.back()
@@ -79,23 +78,13 @@ function onCancel() {
padding: 0;
min-height: 0;
}
h2 {
.task-assign-view h2 {
font-size: 1.15rem;
color: var(--assign-heading-color);
font-weight: 700;
text-align: center;
margin: 0.2rem;
}
.task-list-scroll {
display: flex;
flex-direction: row;
width: 100%;
max-width: 480px;
justify-content: center;
min-height: 0;
overflow-y: auto;
margin-bottom: 2rem;
}
.task-view {
display: flex;
@@ -107,37 +96,4 @@ h2 {
padding: 0;
min-height: 0;
}
.no-tasks-message {
margin: 2rem 0;
font-size: 1.15rem;
font-weight: 600;
text-align: center;
color: var(--assign-no-items-color);
line-height: 1.5;
}
.sub-message {
margin-top: 0.3rem;
font-size: 1rem;
font-weight: 400;
color: var(--assign-sub-message-color);
}
.create-btn {
background: var(--assign-create-btn-bg);
color: var(--assign-create-btn-color);
border: 2px solid var(--assign-create-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;
}
.create-btn:hover {
background: var(--assign-create-btn-hover-bg);
color: var(--assign-create-btn-hover-color);
}
</style>

View File

@@ -3,7 +3,6 @@ import { ref, onMounted, onUnmounted } from 'vue'
import { getCachedImageUrl } from '../../common/imageCache'
import type { PendingReward, Event, ChildRewardRequestEventPayload } from '@/common/models'
import { eventBus } from '@/common/eventBus'
import '@/assets/list-shared.css'
const emit = defineEmits(['item-clicked'])

View File

@@ -3,7 +3,6 @@ import { ref, onBeforeUnmount, watch, nextTick, computed } from 'vue'
import { defineProps, defineEmits, defineExpose } from 'vue'
import { getCachedImageUrl, revokeAllImageUrls } from '../../common/imageCache'
import type { RewardStatus } from '@/common/models'
import '@/assets/child-list-shared.css'
const imageCacheName = 'images-v1'

View File

@@ -1,178 +0,0 @@
<script setup lang="ts">
import { ref, watch, onMounted, computed } from 'vue'
import { getCachedImageUrl } from '../../common/imageCache'
import type { Reward } from '@/common/models'
import '@/assets/list-shared.css'
const props = defineProps<{
childId?: string | number
assignFilter?: 'assignable' | 'assigned' | 'none'
deletable?: boolean
selectable?: boolean
}>()
const emit = defineEmits(['edit-reward', 'delete-reward', 'loading-complete'])
const rewards = ref<
{
id: string
name: string
description?: string
cost: number
image_id?: string | null
image_url?: string | null
}[]
>([])
const loading = ref(true)
const error = ref<string | null>(null)
const selectedRewards = ref<string[]>([])
const fetchRewards = async () => {
loading.value = true
error.value = null
let url = ''
if (props.childId) {
url = `/api/child/${props.childId}/list-all-rewards`
try {
const resp = await fetch(url)
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
const data = await resp.json()
const assigned = (data.assigned_rewards || []).map((reward: Reward) => ({
...reward,
assigned: true,
}))
const assignable = (data.assignable_rewards || []).map((reward: Reward) => ({
...reward,
assigned: false,
}))
let rewardList: any[] = []
if (props.assignFilter === 'assignable') {
rewardList = assignable
} else if (props.assignFilter === 'assigned') {
rewardList = assigned
} else if (props.assignFilter === 'none') {
rewardList = []
} else {
rewardList = [...assigned, ...assignable]
}
// Fetch images for each reward if image_id is present
await Promise.all(
rewardList.map(async (reward: any) => {
if (reward.image_id) {
try {
reward.image_url = await getCachedImageUrl(reward.image_id)
} catch (e) {
reward.image_url = null
}
}
}),
)
rewards.value = rewardList
// If selectable, pre-select assigned rewards
if (props.selectable) {
selectedRewards.value = assigned.map((reward: Reward) => String(reward.id))
}
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to fetch rewards'
rewards.value = []
if (props.selectable) selectedRewards.value = []
} finally {
emit('loading-complete', rewards.value.length)
loading.value = false
}
} else {
url = '/api/reward/list'
try {
const resp = await fetch(url)
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
const data = await resp.json()
const rewardList = data.rewards || []
await Promise.all(
rewardList.map(async (reward: any) => {
if (reward.image_id) {
try {
reward.image_url = await getCachedImageUrl(reward.image_id)
} catch (e) {
reward.image_url = null
}
}
}),
)
rewards.value = rewardList
if (props.selectable) selectedRewards.value = []
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to fetch rewards'
rewards.value = []
if (props.selectable) selectedRewards.value = []
} finally {
emit('loading-complete', rewards.value.length)
loading.value = false
}
}
}
onMounted(fetchRewards)
watch(() => [props.childId, props.assignFilter], fetchRewards)
const handleEdit = (rewardId: string) => {
emit('edit-reward', rewardId)
}
const handleDelete = (rewardId: string) => {
emit('delete-reward', rewardId)
}
defineExpose({ refresh: fetchRewards, selectedRewards })
const ITEM_HEIGHT = 52 // px, adjust to match your .reward-list-item + margin
const listHeight = computed(() => {
const n = rewards.value.length
return `${Math.min(n * ITEM_HEIGHT + 8, window.innerHeight - 80)}px`
})
</script>
<template>
<div class="listbox" :style="{ maxHeight: `min(${listHeight}, calc(100vh - 4.5rem))` }">
<div v-if="loading" class="loading">Loading rewards...</div>
<div v-else-if="error" class="error">{{ error }}</div>
<div v-else-if="rewards.length === 0" class="empty">No rewards found.</div>
<div v-else>
<div v-for="(reward, idx) in rewards" :key="reward.id">
<div class="list-item" @click="handleEdit(reward.id)">
<img v-if="reward.image_url" :src="reward.image_url" alt="Reward" class="list-image" />
<span class="list-name">{{ reward.name }}</span>
<span class="list-cost"> {{ reward.cost }} pts </span>
<input
v-if="props.selectable"
type="checkbox"
class="list-checkbox"
v-model="selectedRewards"
:value="reward.id"
@click.stop
/>
<button
v-if="props.deletable"
class="delete-btn"
@click.stop="handleDelete(reward.id)"
aria-label="Delete reward"
type="button"
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
<circle cx="10" cy="10" r="9" fill="#fff" stroke="#ef4444" stroke-width="2" />
<path
d="M7 7l6 6M13 7l-6 6"
stroke="#ef4444"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
</button>
</div>
<div v-if="idx < rewards.length - 1" class="list-separator"></div>
</div>
</div>
</div>
</template>
<style scoped></style>

View File

@@ -1,46 +1,42 @@
<template>
<div class="reward-view">
<div v-if="rewardCountRef == 0" class="no-message">
<div>No rewards</div>
<div class="sub-message">
<button class="create-btn" @click="createReward">Create</button> a reward
</div>
</div>
<MessageBlock v-if="rewardCountRef === 0" message="No rewards">
<span> <button class="round-btn" @click="createReward">Create</button> a reward </span>
</MessageBlock>
<div class="reward-view" v-else>
<RewardList
ref="rewardListRef"
:deletable="true"
@edit-reward="(rewardId) => $router.push({ name: 'EditReward', params: { id: rewardId } })"
@delete-reward="confirmDeleteReward"
@loading-complete="(count) => (rewardCountRef = count)"
/>
</div>
<ItemList
v-else
fetchUrl="/api/reward/list"
itemKey="rewards"
:itemFields="['id', 'name', 'cost', 'description', 'image_id']"
imageField="image_id"
deletable
@edit="(rewardId) => $router.push({ name: 'EditReward', params: { id: rewardId } })"
@delete="confirmDeleteReward"
@loading-complete="(count) => (rewardCountRef = count)"
/>
<!-- Floating Action Button -->
<button class="fab" @click="createReward" aria-label="Create Reward">
<svg width="28" height="28" viewBox="0 0 28 28" fill="none">
<circle cx="14" cy="14" r="14" fill="#667eea" />
<path d="M14 8v12M8 14h12" stroke="#fff" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
<FloatingActionButton aria-label="Create Reward" @click="createReward" />
<div v-if="showConfirm" class="modal-backdrop">
<div class="modal">
<p>Are you sure you want to delete this reward?</p>
<div class="actions">
<button @click="deleteReward" class="btn btn-danger">Delete</button>
<button @click="showConfirm = false" class="btn btn-secondary">Cancel</button>
</div>
</div>
</div>
<DeleteModal
:show="showConfirm"
message="Are you sure you want to delete this reward?"
@confirm="deleteReward"
@cancel="showConfirm = false"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import RewardList from './RewardList.vue'
import ItemList from '../shared/ItemList.vue'
import MessageBlock from '@/components/shared/MessageBlock.vue'
import '@/assets/button-shared.css'
import FloatingActionButton from '../shared/FloatingActionButton.vue'
import DeleteModal from '../shared/DeleteModal.vue'
import '@/assets/view-shared.css'
const $router = useRouter()
@@ -87,23 +83,4 @@ const createReward = () => {
padding: 0;
min-height: 0;
}
.create-btn {
background: var(--create-btn-bg);
color: var(--create-btn-color);
border: 2px solid var(--create-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;
}
.create-btn:hover {
background: var(--create-btn-hover-bg);
color: var(--create-btn-hover-color);
}
</style>

View File

@@ -12,10 +12,11 @@ import type {
Event,
} from '@/common/models'
import MessageBlock from '@/components/shared/MessageBlock.vue'
import '@/assets/common.css'
import '@/assets/modal.css'
import '@/assets/button-shared.css'
import FloatingActionButton from '../shared/FloatingActionButton.vue'
import DeleteModal from '../shared/DeleteModal.vue'
const router = useRouter()
const children = ref<Child[]>([])
const loading = ref(true)
@@ -345,41 +346,18 @@ onBeforeUnmount(() => {
</div>
</div>
<!-- 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 btn-secondary"
@click="
() => {
confirmDeleteVisible = false
deletingChildId = null
}
"
>
Cancel
</button>
<button class="btn btn-danger" @click="performDelete" :disabled="deleting">
{{ deleting ? 'Deleting…' : 'Delete' }}
</button>
</div>
</div>
</div>
<DeleteModal
:show="confirmDeleteVisible"
message="Are you sure you want to delete this child?"
@confirm="performDelete"
@cancel="confirmDeleteVisible = false"
/>
<!-- Add Child button (FAB) -->
<button v-if="isParentAuthenticated" class="fab" @click="createChild" aria-label="Add Child">
<svg width="28" height="28" viewBox="0 0 28 28" fill="none">
<circle cx="14" cy="14" r="14" fill="#667eea" />
<path d="M14 8v12M8 14h12" stroke="#fff" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
<FloatingActionButton
v-if="isParentAuthenticated"
aria-label="Add Child"
@click="createChild"
/>
</div>
</template>
@@ -526,4 +504,27 @@ onBeforeUnmount(() => {
font-weight: 600;
text-align: center;
}
/* Loading, error, empty states */
.loading,
.empty {
margin: 1.2rem 0;
color: var(--list-loading-color);
font-size: 1.15rem;
font-weight: 600;
text-align: center;
line-height: 1.5;
}
.error {
color: var(--error);
margin-top: 0.7rem;
text-align: center;
background: var(--error-bg);
border-radius: 8px;
padding: 1rem;
}
.value {
font-weight: 600;
margin-left: 1rem;
}
</style>

View File

@@ -0,0 +1,48 @@
<template>
<div class="modal-backdrop" v-if="show">
<div class="modal">
<p>{{ message }}</p>
<div class="actions">
<button @click="handleDelete" class="btn btn-danger" :disabled="deleting">
{{ deleting ? 'Deleting' : 'Delete' }}
</button>
<button @click="handleCancel" class="btn btn-secondary" :disabled="deleting">Cancel</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
const props = defineProps<{
show: boolean
message?: string
}>()
const emit = defineEmits(['confirm', 'cancel'])
const deleting = ref(false)
function handleDelete() {
deleting.value = true
emit('confirm')
}
function handleCancel() {
if (!deleting.value) emit('cancel')
}
// Reset deleting state when modal is closed
watch(
() => props.show,
(val) => {
if (!val) deleting.value = false
},
)
</script>
<style>
@import '@/assets/modal.css';
@import '@/assets/actions-shared.css';
</style>

View File

@@ -0,0 +1,45 @@
<template>
<button class="fab" @click="$emit('click')" :aria-label="ariaLabel">
<svg width="28" height="28" viewBox="0 0 28 28" fill="none">
<circle cx="14" cy="14" r="14" fill="#667eea" />
<path d="M14 8v12M8 14h12" stroke="#fff" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
</template>
<script setup lang="ts">
import '@/assets/global.css'
defineProps<{ ariaLabel?: string }>()
</script>
<style scoped>
.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);
}
svg {
display: block;
}
</style>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
import { ref, watch, onMounted, computed } from 'vue'
import { getCachedImageUrl } from '@/common/imageCache'
const props = defineProps<{
@@ -21,13 +21,21 @@ const loading = ref(true)
const error = ref<string | null>(null)
const selectedItems = ref<string[]>([])
defineExpose({
items,
selectedItems,
})
const fetchItems = async () => {
loading.value = true
error.value = null
console.log(`Fetching items from: ${props.fetchUrl}`)
try {
const resp = await fetch(props.fetchUrl)
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
const data = await resp.json()
//console log all data
console.log('Fetched data:', data)
let itemList = data[props.itemKey] || []
if (props.filterFn) itemList = itemList.filter(props.filterFn)
await Promise.all(
@@ -38,11 +46,14 @@ const fetchItems = async () => {
} catch {
item.image_url = null
}
//for each item see it there is an 'assigned' field that is true. if so check the item's selectable checkbox
if (props.selectable && item.assigned === true) {
selectedItems.value.push(item.id)
}
}
}),
)
items.value = itemList
if (props.selectable) selectedItems.value = []
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to fetch items'
items.value = []
@@ -53,8 +64,17 @@ const fetchItems = async () => {
}
}
const getItemType = (itemList) => {
if (itemList.length === 0) return 'reward'
if (items.value[0].points !== undefined) {
return items.value[0].is_good ? 'good' : 'bad'
}
return 'reward'
}
onMounted(fetchItems)
watch(() => props.fetchUrl, fetchItems)
const itemType = computed(() => getItemType(items))
const handleEdit = (id: string) => {
emit('edit', id)
@@ -73,11 +93,11 @@ const handleDelete = (id: string) => {
<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)">
<div class="list-item" :class="['list-item', itemType]" @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>
<span v-if="item.points !== undefined" class="value">{{ item.points }} pts</span>
<span v-if="item.cost !== undefined" class="value">{{ item.cost }} pts</span>
<input
v-if="props.selectable"
type="checkbox"
@@ -111,10 +131,24 @@ const handleDelete = (id: string) => {
</div>
</template>
<style scoped>
.listbox {
flex: 0 1 auto;
max-width: 480px;
width: 100%;
max-height: calc(100vh - 4.5rem);
overflow-y: auto;
margin: 0.2rem 0 0 0;
display: flex;
flex-direction: column;
gap: 0.7rem;
background: var(--list-bg);
padding: 0.2rem 0.2rem 0.2rem;
border-radius: 12px;
}
.list-item {
display: flex;
align-items: center;
border: 2px outset var(--list-item-border-good);
border: 2px outset var(--list-item-border-reward);
border-radius: 8px;
padding: 0.2rem 1rem;
background: var(--list-item-bg);
@@ -131,6 +165,10 @@ const handleDelete = (id: string) => {
border-color: var(--list-item-border-bad);
background: var(--list-item-bg-bad);
}
.list-item.reward {
border-color: var(--list-item-border-reward);
background: var(--list-item-bg-reward);
}
.list-item.good {
border-color: var(--list-item-border-good);
background: var(--list-item-bg-good);
@@ -160,4 +198,67 @@ const handleDelete = (id: string) => {
text-align: right;
font-weight: 600;
}
.delete-btn {
background: transparent;
border: none;
border-radius: 50%;
padding: 0.15rem;
margin-left: 0.7rem;
cursor: pointer;
display: flex;
align-items: center;
transition:
background 0.15s,
box-shadow 0.15s;
width: 2rem;
height: 2rem;
opacity: 0.92;
}
.delete-btn:hover {
background: var(--delete-btn-hover-bg);
box-shadow: 0 0 0 2px var(--delete-btn-hover-shadow);
opacity: 1;
}
.delete-btn svg {
display: block;
}
/* Checkbox */
.list-checkbox {
margin-left: 1rem;
width: 1.2em;
height: 1.2em;
accent-color: var(--checkbox-accent);
cursor: pointer;
}
/* Loading, error, empty states */
.loading,
.empty {
margin: 1.2rem 0;
color: var(--list-loading-color);
font-size: 1.15rem;
font-weight: 600;
text-align: center;
line-height: 1.5;
}
.error {
color: var(--error);
margin-top: 0.7rem;
text-align: center;
background: var(--error-bg);
border-radius: 8px;
padding: 1rem;
}
.value {
font-weight: 600;
margin-left: 1rem;
}
/* Separator (if needed) */
.list-separator {
height: 0px;
background: #0000;
margin: 0rem 0.2rem;
border-radius: 0px;
}
</style>

View File

@@ -3,7 +3,6 @@ import { ref, watch, onBeforeUnmount, nextTick, computed } from 'vue'
import { defineProps, defineEmits } from 'vue'
import { getCachedImageUrl, revokeAllImageUrls } from '../../common/imageCache'
import type { Task } from '@/common/models'
import '@/assets/child-list-shared.css'
const imageCacheName = 'images-v1'

View File

@@ -1,196 +0,0 @@
<script setup lang="ts">
import { ref, watch, onMounted, computed } from 'vue'
import { getCachedImageUrl } from '../../common/imageCache'
import type { Task } from '@/common/models'
import '@/assets/list-shared.css'
const props = defineProps<{
childId?: string | number
assignFilter?: 'assignable' | 'assigned' | 'none'
typeFilter?: 'good' | 'bad' | 'all'
deletable?: boolean
selectable?: boolean
}>()
const emit = defineEmits(['edit-task', 'delete-task', 'loading-complete'])
const tasks = ref<
{
id: string
name: string
points: number
is_good: boolean
image_id?: string | null
image_url?: string | null
assigned?: boolean
}[]
>([])
const loading = ref(true)
const error = ref<string | null>(null)
const selectedTasks = ref<string[]>([])
const fetchTasks = async () => {
loading.value = true
error.value = null
let url = ''
if (props.childId) {
url = `/api/child/${props.childId}/list-all-tasks`
try {
const resp = await fetch(url)
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
const data = await resp.json()
const assigned = (data.assigned_tasks || []).map((task: Task) => ({
...task,
assigned: true,
}))
const assignable = (data.assignable_tasks || []).map((task: Task) => ({
...task,
assigned: false,
}))
let taskList: Task[] = []
if (props.assignFilter === 'assignable') {
taskList = assignable
} else if (props.assignFilter === 'assigned') {
taskList = assigned
} else if (props.assignFilter === 'none') {
taskList = []
} else {
taskList = [...assigned, ...assignable]
}
// Fetch images for each task if image_id is present
await Promise.all(
taskList.map(async (task: Task) => {
if (task.image_id) {
try {
task.image_url = await getCachedImageUrl(task.image_id)
} catch (e) {
task.image_url = null
}
}
}),
)
tasks.value = taskList
// If selectable, pre-select assigned tasks
if (props.selectable) {
selectedTasks.value = assigned.map((task: Task) => String(task.id))
}
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to fetch tasks'
tasks.value = []
if (props.selectable) selectedTasks.value = []
} finally {
loading.value = false
emit('loading-complete', filteredTasks.value.length)
}
} else {
url = '/api/task/list'
try {
const resp = await fetch(url)
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
const data = await resp.json()
const taskList = data.tasks || []
await Promise.all(
taskList.map(async (task: Task) => {
if (task.image_id) {
try {
task.image_url = await getCachedImageUrl(task.image_id)
} catch (e) {
task.image_url = null
}
}
}),
)
tasks.value = taskList
if (props.selectable) selectedTasks.value = []
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to fetch tasks'
tasks.value = []
if (props.selectable) selectedTasks.value = []
} finally {
emit('loading-complete', filteredTasks.value.length)
loading.value = false
}
}
}
onMounted(fetchTasks)
watch(() => [props.childId, props.assignFilter], fetchTasks)
const handleEdit = (taskId: string) => {
emit('edit-task', taskId)
}
const handleDelete = (taskId: string) => {
emit('delete-task', taskId)
}
defineExpose({ refresh: fetchTasks, selectedTasks })
const ITEM_HEIGHT = 52 // px, adjust to match your .task-list-item + margin
const listHeight = computed(() => {
// Add a little for padding, separators, etc.
const n = tasks.value.length
return `${Math.min(n * ITEM_HEIGHT + 8, window.innerHeight - 80)}px`
})
const filteredTasks = computed(() => {
if (props.typeFilter === 'good') {
return tasks.value.filter((t) => t.is_good)
} else if (props.typeFilter === 'bad') {
return tasks.value.filter((t) => !t.is_good)
}
return tasks.value
})
</script>
<template>
<div class="listbox" :style="{ maxHeight: `min(${listHeight}, calc(100vh - 4.5rem))` }">
<div v-if="loading" class="loading">Loading tasks...</div>
<div v-else-if="error" class="error">{{ error }}</div>
<div v-else-if="tasks.length === 0" class="empty">No tasks found.</div>
<div v-else>
<div v-for="(task, idx) in filteredTasks" :key="task.id">
<div
class="list-item"
:class="{ good: task.is_good, bad: !task.is_good }"
@click="handleEdit(task.id)"
>
<img v-if="task.image_url" :src="task.image_url" alt="Task" class="list-image" />
<span class="list-name">{{ task.name }}</span>
<span class="list-points">
{{ task.is_good ? task.points : '-' + task.points }} pts
</span>
<!-- Add checkbox if selectable -->
<input
v-if="props.selectable"
type="checkbox"
class="list-checkbox"
v-model="selectedTasks"
:value="task.id"
@click.stop
/>
<button
v-if="props.deletable"
@click.stop="handleDelete(task.id)"
aria-label="Delete task"
type="button"
class="delete-btn"
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
<circle cx="10" cy="10" r="9" fill="#fff" stroke="#ef4444" stroke-width="2" />
<path
d="M7 7l6 6M13 7l-6 6"
stroke="#ef4444"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
</button>
</div>
<div v-if="idx < tasks.length - 1" class="list-separator"></div>
</div>
</div>
</div>
</template>
<style scoped></style>

View File

@@ -1,11 +1,8 @@
<template>
<div class="task-view">
<div v-if="taskCountRef === 0" class="no-message">
<div>No tasks</div>
<div class="sub-message">
<button class="create-btn" @click="createTask">Create</button> a task
</div>
</div>
<MessageBlock v-if="taskCountRef === 0" message="No tasks">
<span> <button class="round-btn" @click="createTask">Create</button> a task </span>
</MessageBlock>
<ItemList
v-else
@@ -13,38 +10,33 @@
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)"
/>
<!-- Floating Action Button -->
<button class="fab" @click="createTask" aria-label="Create Task">
<svg width="28" height="28" viewBox="0 0 28 28" fill="none">
<circle cx="14" cy="14" r="14" fill="#667eea" />
<path d="M14 8v12M8 14h12" stroke="#fff" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
<FloatingActionButton aria-label="Create Task" @click="createTask" />
<div v-if="showConfirm" class="modal-backdrop">
<div class="modal">
<p>Are you sure you want to delete this task?</p>
<div class="actions">
<button @click="deleteTask" class="btn btn-danger">Delete</button>
<button @click="showConfirm = false" class="btn btn-secondary">Cancel</button>
</div>
</div>
</div>
<DeleteModal
:show="showConfirm"
message="Are you sure you want to delete this task?"
@confirm="deleteTask"
@cancel="showConfirm = false"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import TaskList from './TaskList.vue'
import ItemList from '../shared/ItemList.vue'
import MessageBlock from '@/components/shared/MessageBlock.vue'
import '@/assets/button-shared.css'
import FloatingActionButton from '../shared/FloatingActionButton.vue'
import DeleteModal from '../shared/DeleteModal.vue'
import '@/assets/view-shared.css'
const $router = useRouter()
@@ -94,23 +86,4 @@ const createTask = () => {
padding: 0;
min-height: 0;
}
.create-btn {
background: var(--create-btn-bg);
color: var(--create-btn-color);
border: 2px solid var(--create-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;
}
.create-btn:hover {
background: var(--create-btn-hover-bg);
color: var(--create-btn-hover-color);
}
</style>