Refactor components to use ModalDialog and StatusMessage; update styles and remove unused files
Some checks failed
Gitea Actions Demo / build-and-push (push) Failing after 9s

- Replaced inline modal dialogs in ParentView with a reusable ModalDialog component.
- Introduced StatusMessage component for loading and error states in ParentView.
- Updated styles to use new colors.css and styles.css for consistent theming.
- Removed ChildRewardList.vue and ChildTaskList.vue components as they were no longer needed.
- Adjusted RewardAssignView and TaskAssignView to use new styles and shared button styles.
- Cleaned up imports across components to reflect the new styles and removed unused CSS files.
This commit is contained in:
2026-01-22 16:37:53 -05:00
parent a0a059472b
commit 63769fbe32
20 changed files with 438 additions and 674 deletions

View File

@@ -1,20 +1,3 @@
.actions {
display: flex;
gap: 3rem;
justify-content: center;
margin-top: 0.5rem;
}
.actions button {
padding: 1rem 2.2rem;
border-radius: 12px;
border: 0;
cursor: pointer;
font-weight: 700;
font-size: 1.25rem;
transition: background 0.18s;
min-width: 120px;
}
/* Unified error style */ /* Unified error style */
.error { .error {
color: var(--error); color: var(--error);
@@ -25,17 +8,6 @@
padding: 1rem; padding: 1rem;
} }
@media (max-width: 480px) {
.actions {
gap: 1.2rem;
}
.actions button {
padding: 0.8rem 1.2rem;
font-size: 1.05rem;
min-width: 90px;
}
}
/* Error message */ /* Error message */
.error-message { .error-message {
color: var(--error, #e53e3e); color: var(--error, #e53e3e);

View File

@@ -1,65 +1,3 @@
/* Base button style */
.btn {
font-weight: 600;
border: none;
border-radius: 8px;
padding: 0.7rem 1.5rem;
font-size: 1.1rem;
cursor: pointer;
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.08);
transition:
background 0.18s,
color 0.18s;
display: inline-block;
}
/* Primary button (e.g., Save, Confirm) */
.btn-primary {
background: var(--btn-primary);
color: #fff;
}
.btn-primary:hover,
.btn-primary:focus {
background: var(--btn-primary-hover);
}
.btn-primary:disabled,
.btn-primary[disabled] {
background: var(--btn-secondary, #f3f3f3);
color: var(--btn-secondary-text, #666);
cursor: not-allowed;
opacity: 0.7;
}
/* Secondary button (e.g., Cancel) */
.btn-secondary {
background: var(--btn-secondary);
color: var(--btn-secondary-text);
}
.btn-secondary:hover,
.btn-secondary:focus {
background: var(--btn-secondary-hover);
}
/* Danger button (e.g., Delete) */
.btn-danger {
background: var(--btn-danger);
color: #fff;
}
.btn-danger:hover,
.btn-danger:focus {
background: var(--btn-danger-hover);
}
/* Green button (e.g., Confirm) */
.btn-green {
background: var(--btn-green);
color: #fff;
}
.btn-green:hover,
.btn-green:focus {
background: var(--btn-green-hover);
}
.form-btn { .form-btn {
padding: 0.6rem 1rem; padding: 0.6rem 1rem;
border-radius: 8px; border-radius: 8px;
@@ -81,41 +19,3 @@
background: var(--btn-primary-hover, #5a67d8); background: var(--btn-primary-hover, #5a67d8);
transform: translateY(-1px); transform: translateY(-1px);
} }
/* Link-style button */
.btn-link {
color: var(--btn-primary);
text-decoration: underline;
background: none;
border: none;
padding: 0;
cursor: pointer;
font-weight: 600;
margin-left: 6px;
}
.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);
}

View File

@@ -37,8 +37,8 @@
--list-item-border-reward: #38c172; --list-item-border-reward: #38c172;
--list-item-border-bad: #e53e3e; --list-item-border-bad: #e53e3e;
--list-item-border-good: #00e1ff; --list-item-border-good: #00e1ff;
--list-item-bg-reward: #94ffb1; --list-item-bg-reward: #4ed271;
--list-item-bg-bad: #ffc5c5; --list-item-bg-bad: #f98a8a;
--list-item-bg-good: #8dabfd; --list-item-bg-good: #8dabfd;
--list-image-bg: #eee; --list-image-bg: #eee;
--delete-btn-hover-bg: #ffeaea; --delete-btn-hover-bg: #ffeaea;

View File

@@ -0,0 +1,100 @@
/*buttons*/
.btn {
font-weight: 600;
border: none;
border-radius: 8px;
padding: 0.7rem 1.5rem;
font-size: 1.1rem;
cursor: pointer;
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.08);
transition:
background 0.18s,
color 0.18s;
display: inline-block;
}
/* Primary button (e.g., Save, Confirm) */
.btn-primary {
background: var(--btn-primary);
color: #fff;
}
.btn-primary:hover,
.btn-primary:focus {
background: var(--btn-primary-hover);
}
.btn-primary:disabled,
.btn-primary[disabled] {
background: var(--btn-secondary, #f3f3f3);
color: var(--btn-secondary-text, #666);
cursor: not-allowed;
opacity: 0.7;
}
/* Secondary button (e.g., Cancel) */
.btn-secondary {
background: var(--btn-secondary);
color: var(--btn-secondary-text);
}
.btn-secondary:hover,
.btn-secondary:focus {
background: var(--btn-secondary-hover);
}
/* Danger button (e.g., Delete) */
.btn-danger {
background: var(--btn-danger);
color: #fff;
}
.btn-danger:hover,
.btn-danger:focus {
background: var(--btn-danger-hover);
}
/* Green button (e.g., Confirm) */
.btn-green {
background: var(--btn-green);
color: #fff;
}
.btn-green:hover,
.btn-green:focus {
background: var(--btn-green-hover);
}
/* Link-style button */
.btn-link {
color: var(--btn-primary);
text-decoration: underline;
background: none;
border: none;
padding: 0;
cursor: pointer;
font-weight: 600;
margin-left: 6px;
}
.btn-link:disabled {
text-decoration: none;
opacity: 0.75;
cursor: default;
pointer-events: none;
color: var(--btn-primary);
}
/* Rounded button */
.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);
}

View File

@@ -41,20 +41,6 @@
text-align: center; text-align: center;
} }
/* Dialog Message */
.dialog-message {
font-size: 1.08rem;
color: var(--dialog-message);
font-weight: 500;
margin-bottom: 1.2rem;
text-align: center;
}
.dialog-message .child-name {
color: var(--dialog-child-name);
font-weight: 700;
margin-left: 2px;
}
/* Info Sections (Reward/Task) */ /* Info Sections (Reward/Task) */
.info, .info,
.reward-info, .reward-info,

View File

@@ -81,7 +81,7 @@ import { ref, computed } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { isEmailValid } from '@/common/api' import { isEmailValid } from '@/common/api'
import '@/assets/view-shared.css' import '@/assets/view-shared.css'
import '@/assets/global.css' import '@/assets/colors.css'
import '@/assets/edit-forms.css' import '@/assets/edit-forms.css'
import '@/assets/actions-shared.css' import '@/assets/actions-shared.css'
import '@/assets/button-shared.css' import '@/assets/button-shared.css'

View File

@@ -140,7 +140,7 @@
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import '@/assets/view-shared.css' import '@/assets/view-shared.css'
import '@/assets/global.css' import '@/assets/colors.css'
import '@/assets/edit-forms.css' import '@/assets/edit-forms.css'
import '@/assets/actions-shared.css' import '@/assets/actions-shared.css'
import '@/assets/button-shared.css' import '@/assets/button-shared.css'

View File

@@ -138,7 +138,7 @@ import { ref, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { isPasswordStrong } from '@/common/api' import { isPasswordStrong } from '@/common/api'
import '@/assets/view-shared.css' import '@/assets/view-shared.css'
import '@/assets/global.css' import '@/assets/colors.css'
import '@/assets/edit-forms.css' import '@/assets/edit-forms.css'
import '@/assets/actions-shared.css' import '@/assets/actions-shared.css'
import '@/assets/button-shared.css' import '@/assets/button-shared.css'

View File

@@ -104,7 +104,7 @@
<ErrorMessage v-if="signupError" :message="signupError" aria-live="polite" /> <ErrorMessage v-if="signupError" :message="signupError" aria-live="polite" />
<!-- Modal for "Account already exists" --> <!-- Modal for "Account already exists" -->
<ModalDialog v-if="showEmailExistsModal"> <ModalDialog v-if="showEmailExistsModal" :imageUrl="undefined">
<h3>Account already exists</h3> <h3>Account already exists</h3>
<p> <p>
An account with <strong>{{ email }}</strong> already exists. An account with <strong>{{ email }}</strong> already exists.
@@ -156,7 +156,7 @@ import { EMAIL_EXISTS, MISSING_FIELDS } from '@/common/errorCodes'
import '@/assets/view-shared.css' import '@/assets/view-shared.css'
import '@/assets/actions-shared.css' import '@/assets/actions-shared.css'
import '@/assets/button-shared.css' import '@/assets/button-shared.css'
import '@/assets/global.css' import '@/assets/colors.css'
import '@/assets/edit-forms.css' import '@/assets/edit-forms.css'
const router = useRouter() const router = useRouter()

View File

@@ -1,10 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onUnmounted, computed } from 'vue' import { ref, onMounted, onUnmounted, computed } from 'vue'
import ModalDialog from '../shared/ModalDialog.vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import ChildDetailCard from './ChildDetailCard.vue' import ChildDetailCard from './ChildDetailCard.vue'
import ScrollingList from '../shared/ScrollingList.vue' import ScrollingList from '../shared/ScrollingList.vue'
import { eventBus } from '@/common/eventBus' import { eventBus } from '@/common/eventBus'
import '@/assets/view-shared.css' import '@/assets/view-shared.css'
import '@/assets/styles.css'
import type { import type {
Child, Child,
Event, Event,
@@ -317,8 +319,91 @@ onUnmounted(() => {
<template> <template>
<div> <div>
<div v-if="loading" class="loading">Loading...</div> <StatusMessage :loading="loading" :error="error" />
<div v-else-if="error" class="error">Error: {{ error }}</div>
<div v-if="!loading && !error" class="layout">
<div class="main">
<ChildDetailCard :child="child" />
<ScrollingList
title="Chores"
ref="childChoreListRef"
:fetchBaseUrl="`/api/task/list?ids=${tasks.join(',')}`"
:ids="tasks"
itemKey="tasks"
imageField="image_id"
@trigger-item="triggerTask"
:getItemClass="(item) => ({ bad: !item.is_good, good: item.is_good })"
:filter-fn="
(item) => {
return item.is_good
}
"
>
<template #item="{ item }">
<div class="item-name">{{ item.name }}</div>
<img v-if="item.image_url" :src="item.image_url" alt="Task Image" class="item-image" />
<div
class="item-points"
:class="{ 'good-points': item.is_good, 'bad-points': !item.is_good }"
>
{{ item.is_good ? item.points : -item.points }} Points
</div>
</template>
</ScrollingList>
<ScrollingList
title="Penalties"
ref="childPenaltyListRef"
:fetchBaseUrl="`/api/task/list?ids=${tasks.join(',')}`"
:ids="tasks"
itemKey="tasks"
imageField="image_id"
@trigger-item="triggerTask"
:getItemClass="(item) => ({ bad: !item.is_good, good: item.is_good })"
:filter-fn="
(item) => {
return !item.is_good
}
"
>
<template #item="{ item }">
<div class="item-name">{{ item.name }}</div>
<img v-if="item.image_url" :src="item.image_url" alt="Task Image" class="item-image" />
<div
class="item-points"
:class="{ 'good-points': item.is_good, 'bad-points': !item.is_good }"
>
{{ item.is_good ? item.points : -item.points }} Points
</div>
</template>
</ScrollingList>
<ScrollingList
title="Rewards"
ref="childRewardListRef"
:fetchBaseUrl="`/api/child/${child?.id}/reward-status`"
itemKey="reward_status"
imageField="image_id"
@trigger-item="triggerReward"
:getItemClass="
(item) => ({ reward: true, disabled: hasPendingRewards && !item.redeeming })
"
>
<template #item="{ item }: { item: RewardStatus }">
<div class="item-name">{{ item.name }}</div>
<img
v-if="item.image_url"
:src="item.image_url"
alt="Reward Image"
class="item-image"
/>
<div class="item-points">
<span v-if="item.redeeming" class="pending">PENDING</span>
<span v-if="item.points_needed <= 0" class="ready">REWARD READY</span>
<span v-else>{{ item.points_needed }} more points</span>
</div>
</template>
</ScrollingList>
</div>
</div>
<div v-else class="layout"> <div v-else class="layout">
<div class="main"> <div class="main">
@@ -404,76 +489,38 @@ onUnmounted(() => {
</div> </div>
</div> </div>
<div v-if="showRewardDialog && dialogReward" class="modal-backdrop"> <ModalDialog
<div class="modal"> v-if="showRewardDialog && dialogReward"
<div class="reward-info"> :imageUrl="dialogReward?.image_url"
<img :title="dialogReward.name"
v-if="dialogReward.image_url" :subtitle="`${dialogReward.cost} pts`"
:src="dialogReward.image_url" >
alt="Reward Image" <div class="modal-message">Would you like to redeem this reward?</div>
class="reward-image" <div class="modal-actions">
/> <button @click="confirmRedeemReward" class="btn btn-primary">Yes</button>
<div class="reward-details"> <button @click="cancelRedeemReward" class="btn btn-secondary">No</button>
<div class="reward-name">{{ dialogReward.name }}</div>
<div class="reward-points">{{ dialogReward.cost }} pts</div>
</div>
</div>
<div class="dialog-message" style="margin-bottom: 1.2rem">
Would you like to redeem this reward?
</div>
<div class="actions">
<button @click="confirmRedeemReward" class="btn btn-primary">Yes</button>
<button @click="cancelRedeemReward" class="btn btn-secondary">No</button>
</div>
</div> </div>
</div> </ModalDialog>
<div v-if="showCancelDialog && dialogReward" class="modal-backdrop"> <ModalDialog
<div class="modal"> v-if="showCancelDialog && dialogReward"
<div class="reward-info"> :imageUrl="dialogReward?.image_url"
<img :title="dialogReward.name"
v-if="dialogReward.image_url" :subtitle="`${dialogReward.cost} pts`"
:src="dialogReward.image_url" >
alt="Reward Image" <div class="modal-message">
class="reward-image" This reward is pending.<br />
/> Would you like to cancel the pending reward request?
<div class="reward-details">
<div class="reward-name">{{ dialogReward.name }}</div>
<div class="reward-points">{{ dialogReward.cost }} pts</div>
</div>
</div>
<div class="dialog-message" style="margin-bottom: 1.2rem">
This reward is pending.<br />
Would you like to cancel the pending reward request?
</div>
<div class="actions">
<button @click="cancelPendingReward" class="btn btn-primary">Yes</button>
<button @click="closeCancelDialog" class="btn btn-secondary">No</button>
</div>
</div> </div>
</div> <div class="modal-actions">
<button @click="cancelPendingReward" class="btn btn-primary">Yes</button>
<button @click="closeCancelDialog" class="btn btn-secondary">No</button>
</div>
</ModalDialog>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.assign-buttons {
display: flex;
gap: 1rem;
justify-content: center;
margin: 2rem 0;
}
.back-btn {
background: var(--back-btn-bg);
border: 0;
padding: 0.6rem 1rem;
border-radius: 8px;
cursor: pointer;
margin-bottom: 1.5rem;
color: var(--back-btn-color);
font-weight: 600;
}
.item-points { .item-points {
color: var(--item-points-color, #ffd166); color: var(--item-points-color, #ffd166);
font-size: 1rem; font-size: 1rem;

View File

@@ -1,9 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, computed, onUnmounted } from 'vue' import { ref, onMounted, onUnmounted } from 'vue'
import ModalDialog from '../shared/ModalDialog.vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import ChildDetailCard from './ChildDetailCard.vue' import ChildDetailCard from './ChildDetailCard.vue'
import ScrollingList from '../shared/ScrollingList.vue' import ScrollingList from '../shared/ScrollingList.vue'
import { eventBus } from '@/common/eventBus' import { eventBus } from '@/common/eventBus'
import '@/assets/styles.css'
import '@/assets/view-shared.css' import '@/assets/view-shared.css'
import type { import type {
Task, Task,
@@ -338,10 +340,9 @@ function goToAssignRewards() {
<template> <template>
<div> <div>
<div v-if="loading" class="loading">Loading...</div> <StatusMessage :loading="loading" :error="error" />
<div v-else-if="error" class="error">Error: {{ error }}</div>
<div v-else class="layout"> <div v-if="!loading && !error" class="layout">
<div class="main"> <div class="main">
<ChildDetailCard :child="child" /> <ChildDetailCard :child="child" />
<ScrollingList <ScrollingList
@@ -431,98 +432,74 @@ function goToAssignRewards() {
</div> </div>
<!-- Pending Reward Dialog --> <!-- Pending Reward Dialog -->
<div v-if="showPendingRewardDialog" class="modal-backdrop"> <ModalDialog v-if="showPendingRewardDialog" :title="'Warning!'">
<div class="modal"> <div class="modal-message">
<div class="dialog-message" style="margin-bottom: 1.2rem"> There is a pending reward request. The reward must be cancelled before triggering a new
There is a pending reward request. The reward must be cancelled before triggering a new task.<br />
task.<br /> Would you like to cancel the pending reward?
Would you like to cancel the pending reward?
</div>
<div class="actions">
<button @click="cancelPendingReward" class="btn btn-primary">Yes</button>
<button @click="showPendingRewardDialog = false" class="btn btn-secondary">No</button>
</div>
</div> </div>
</div> <div class="modal-actions">
<button @click="cancelPendingReward" class="btn btn-primary">Yes</button>
<button @click="showPendingRewardDialog = false" class="btn btn-secondary">No</button>
</div>
</ModalDialog>
<div v-if="showConfirm && selectedTask" class="modal-backdrop"> <ModalDialog
<div class="modal"> v-if="showConfirm && selectedTask"
<div class="task-info"> :title="'Confirm Task'"
<img :subtitle="selectedTask.name"
v-if="selectedTask.image_url" :imageUrl="selectedTask.image_url"
:src="selectedTask.image_url" >
alt="Task Image" <div class="modal-message">
class="task-image" {{ selectedTask.is_good ? 'Add' : 'Subtract' }} these points
/> {{ selectedTask.is_good ? 'to' : 'from' }}
<div class="task-details"> <span class="child-name">{{ child?.name }}</span>
<div class="task-name">{{ selectedTask.name }}</div>
<div class="task-points" :class="selectedTask.is_good ? 'good' : 'bad'">
{{ selectedTask.points }} points
</div>
</div>
</div>
<div class="dialog-message" style="margin-bottom: 1.2rem">
{{ selectedTask.is_good ? 'Add' : 'Subtract' }} these points
{{ selectedTask.is_good ? 'to' : 'from' }}
<span class="child-name">{{ child?.name }}</span>
</div>
<div class="actions">
<button @click="confirmTriggerTask" class="btn btn-primary">Yes</button>
<button
@click="
() => {
showConfirm = false
selectedTask = null
}
"
class="btn btn-secondary"
>
Cancel
</button>
</div>
</div> </div>
</div> <div class="modal-actions">
<button @click="confirmTriggerTask" class="btn btn-primary">Yes</button>
<button
@click="
() => {
showConfirm = false
selectedTask = null
}
"
class="btn btn-secondary"
>
Cancel
</button>
</div>
</ModalDialog>
<div v-if="showRewardConfirm && selectedReward" class="modal-backdrop"> <ModalDialog
<div class="modal"> v-if="showRewardConfirm && selectedReward"
<div class="reward-info"> :imageUrl="selectedReward?.image_url"
<img :title="selectedReward?.name"
v-if="selectedReward.image_url" :subtitle="
:src="selectedReward.image_url" selectedReward.points_needed === 0
alt="Reward Image" ? 'Reward Ready!'
class="reward-image" : selectedReward?.points_needed + ' more points'
/> "
<div class="reward-details"> >
<div class="reward-name">{{ selectedReward.name }}</div> <div class="modal-message">
<div class="reward-points"> Redeem this reward for <span class="child-name">{{ child?.name }}</span
{{ >?
selectedReward.points_needed === 0
? 'Reward Ready!'
: selectedReward.points_needed + ' more points'
}}
</div>
</div>
</div>
<div class="dialog-message" style="margin-bottom: 1.2rem">
Redeem this reward for <span class="child-name">{{ child?.name }}</span
>?
</div>
<div class="actions">
<button @click="confirmTriggerReward" class="btn btn-primary">Yes</button>
<button
@click="
() => {
showRewardConfirm = false
selectedReward = null
}
"
class="btn btn-secondary"
>
Cancel
</button>
</div>
</div> </div>
</div> <div class="modal-actions">
<button @click="confirmTriggerReward" class="btn btn-primary">Yes</button>
<button
@click="
() => {
showRewardConfirm = false
selectedReward = null
}
"
class="btn btn-secondary"
>
Cancel
</button>
</div>
</ModalDialog>
</div> </div>
</template> </template>
@@ -534,17 +511,6 @@ function goToAssignRewards() {
margin: 2rem 0; margin: 2rem 0;
} }
.back-btn {
background: var(--back-btn-bg);
border: 0;
padding: 0.6rem 1rem;
border-radius: 8px;
cursor: pointer;
margin-bottom: 1.5rem;
color: var(--back-btn-color);
font-weight: 600;
}
.item-points { .item-points {
color: var(--item-points-color, #ffd166); color: var(--item-points-color, #ffd166);
font-size: 1rem; font-size: 1rem;

View File

@@ -35,7 +35,7 @@ import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import ItemList from '../shared/ItemList.vue' import ItemList from '../shared/ItemList.vue'
import MessageBlock from '../shared/MessageBlock.vue' import MessageBlock from '../shared/MessageBlock.vue'
import '@/assets/actions-shared.css' import '@/assets/styles.css'
import { REWARD_FIELDS } from '@/common/models' import { REWARD_FIELDS } from '@/common/models'
const route = useRoute() const route = useRoute()
@@ -115,4 +115,21 @@ function onCancel() {
border-color: var(--list-item-border-reward); border-color: var(--list-item-border-reward);
background: var(--list-item-bg-reward); background: var(--list-item-bg-reward);
} }
.actions {
display: flex;
gap: 3rem;
justify-content: center;
margin-top: 0.5rem;
}
.actions button {
padding: 1rem 2.2rem;
border-radius: 12px;
border: 0;
cursor: pointer;
font-weight: 700;
font-size: 1.25rem;
transition: background 0.18s;
min-width: 120px;
}
</style> </style>

View File

@@ -35,7 +35,7 @@ import { ref, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import ItemList from '../shared/ItemList.vue' import ItemList from '../shared/ItemList.vue'
import MessageBlock from '../shared/MessageBlock.vue' import MessageBlock from '../shared/MessageBlock.vue'
import '@/assets/actions-shared.css' import '@/assets/styles.css'
import { TASK_FIELDS } from '@/common/models' import { TASK_FIELDS } from '@/common/models'
const route = useRoute() const route = useRoute()
@@ -126,4 +126,21 @@ function onCancel() {
text-align: right; text-align: right;
font-weight: 600; font-weight: 600;
} }
.actions {
display: flex;
gap: 3rem;
justify-content: center;
margin-top: 0.5rem;
}
.actions button {
padding: 1rem 2.2rem;
border-radius: 12px;
border: 0;
cursor: pointer;
font-weight: 700;
font-size: 1.25rem;
transition: background 0.18s;
min-width: 120px;
}
</style> </style>

View File

@@ -1,180 +0,0 @@
<script setup lang="ts">
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'
const imageCacheName = 'images-v1'
const props = defineProps<{
childId: string | null
childPoints: number
isParentAuthenticated: boolean
}>()
const emit = defineEmits(['trigger-reward'])
const rewards = ref<RewardStatus[]>([])
const loading = ref(true)
const error = ref<string | null>(null)
const scrollWrapper = ref<HTMLDivElement | null>(null)
const rewardRefs = ref<Record<string, HTMLElement | null>>({})
const lastCenteredRewardId = ref<string | null>(null)
const readyRewardId = ref<string | null>(null)
const fetchRewards = async (id: string | number | null) => {
if (!id) {
rewards.value = []
loading.value = false
return
}
loading.value = true
error.value = null
try {
const resp = await fetch(`/api/child/${id}/reward-status`)
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
const data = await resp.json()
rewards.value = data.reward_status
// Fetch images for each reward using shared utility
await Promise.all(rewards.value.map(fetchImage))
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to fetch rewards'
console.error('Error fetching rewards:', err)
} finally {
loading.value = false
}
}
const fetchImage = async (reward: RewardStatus) => {
if (!reward.image_id) {
console.log(`No image ID for reward: ${reward.id}`)
return
}
try {
const url = await getCachedImageUrl(reward.image_id, imageCacheName)
reward.image_id = url
} catch (err) {
console.error('Error fetching image for reward', reward.id, err)
}
}
const centerReward = async (rewardId: string) => {
await nextTick()
const wrapper = scrollWrapper.value
const card = rewardRefs.value[rewardId]
if (wrapper && card) {
const wrapperRect = wrapper.getBoundingClientRect()
const cardRect = card.getBoundingClientRect()
const wrapperScrollLeft = wrapper.scrollLeft
const cardCenter = cardRect.left + cardRect.width / 2
const wrapperCenter = wrapperRect.left + wrapperRect.width / 2
const scrollOffset = cardCenter - wrapperCenter
wrapper.scrollTo({
left: wrapperScrollLeft + scrollOffset,
behavior: 'smooth',
})
}
}
const handleRewardClick = async (rewardId: string) => {
await nextTick()
const wrapper = scrollWrapper.value
const card = rewardRefs.value[rewardId]
if (!wrapper || !card) return
const wrapperRect = wrapper.getBoundingClientRect()
const cardRect = card.getBoundingClientRect()
const cardCenter = cardRect.left + cardRect.width / 2
const cardFullyVisible = cardCenter >= wrapperRect.left && cardCenter <= wrapperRect.right
if (!cardFullyVisible || lastCenteredRewardId.value !== rewardId) {
// Center the reward, but don't trigger
await centerReward(rewardId)
lastCenteredRewardId.value = rewardId
readyRewardId.value = rewardId
return
}
// If already centered and visible, trigger the reward
await triggerReward(rewardId)
readyRewardId.value = null
}
const triggerReward = (rewardId: string) => {
const reward = rewards.value.find((rew) => rew.id === rewardId)
if (!reward) return // Don't trigger if not allowed
emit('trigger-reward', reward, reward.points_needed <= 0, reward.redeeming)
}
watch(
() => props.childId,
(newId) => {
fetchRewards(newId)
},
{ immediate: true },
)
watch(
() => props.childPoints,
() => {
rewards.value.forEach((reward) => {
reward.points_needed = Math.max(0, reward.cost - props.childPoints)
})
},
)
function getPendingRewards(): string[] {
return rewards.value.filter((r) => r.redeeming).map((r) => r.id)
}
// revoke created object URLs when component unmounts to avoid memory leaks
onBeforeUnmount(() => {
revokeAllImageUrls()
})
// expose refresh method for parent component
defineExpose({ refresh: () => fetchRewards(props.childId), getPendingRewards })
const isAnyPending = computed(() => rewards.value.some((r) => r.redeeming))
</script>
<template>
<div class="child-list-container">
<h3>Rewards</h3>
<div v-if="loading" class="loading">Loading rewards...</div>
<div v-else-if="error" class="error">Error: {{ error }}</div>
<div v-else-if="rewards.length === 0" class="empty">No rewards available</div>
<div v-else class="scroll-wrapper" ref="scrollWrapper">
<div class="item-scroll">
<div
v-for="r in rewards"
:key="r.id"
class="item-card"
:class="{
ready: readyRewardId === r.id,
disabled: isAnyPending && !r.redeeming,
}"
:ref="(el) => (rewardRefs[r.id] = el)"
@click="() => handleRewardClick(r.id)"
>
<div class="item-name">{{ r.name }}</div>
<img v-if="r.image_id" :src="r.image_id" alt="Reward Image" class="item-image" />
<div class="item-points" :class="{ ready: r.points_needed === 0 }">
<template v-if="r.points_needed === 0"> REWARD READY </template>
<template v-else> {{ r.points_needed }} more points </template>
</div>
<!-- PENDING block if redeeming is true -->
<div v-if="r.redeeming" class="pending-block">PENDING</div>
</div>
</div>
</div>
</div>
</template>
<style scoped></style>

View File

@@ -8,7 +8,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import '@/assets/global.css' import '@/assets/colors.css'
defineProps<{ ariaLabel?: string }>() defineProps<{ ariaLabel?: string }>()
</script> </script>

View File

@@ -7,7 +7,7 @@
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import '@/assets/global.css' import '@/assets/colors.css'
defineProps<{ message: string }>() defineProps<{ message: string }>()
</script> </script>
<style scoped> <style scoped>

View File

@@ -1,13 +1,28 @@
<template> <template>
<div class="modal-backdrop"> <div class="modal-backdrop">
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-heading">
<img v-if="imageUrl" :src="imageUrl" alt="Dialog Image" class="modal-image" />
<div class="modal-details">
<div class="modal-title">{{ title }}</div>
<div class="modal-subtitle">{{ subtitle }}</div>
</div>
</div>
<slot /> <slot />
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
// No script content needed unless you want to add props or logic import '@/assets/colors.css'
defineProps<{
imageUrl?: string | null | undefined
title?: string
subtitle?: string | null | undefined
}>()
</script> </script>
<style scoped> <style scoped>
.modal-backdrop { .modal-backdrop {
position: fixed; position: fixed;
@@ -22,11 +37,74 @@
z-index: 1000; z-index: 1000;
} }
.modal-dialog { .modal-dialog {
background: #fff; background: var(--modal-bg, #fff);
padding: 2rem; padding: 2rem;
border-radius: 12px; border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18); box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
max-width: 340px; max-width: 340px;
text-align: center; text-align: center;
} }
.modal-image {
width: 72px;
height: 72px;
object-fit: cover;
border-radius: 8px;
background: var(--info-image-bg);
}
.modal-heading {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
}
.modal-details {
display: flex;
flex-direction: column;
align-items: center;
margin: auto;
}
.modal-title {
font-weight: 600;
font-size: 1.1rem;
}
.modal-subtitle {
color: var(--info-points);
font-weight: 500;
font-size: 1rem;
}
/* Encapsulated slot content styles */
.modal-message {
margin-bottom: 1.2rem;
font-size: 1rem;
color: var(--modal-message-color, #333);
}
:deep(.modal-actions) {
display: flex;
gap: 3rem;
justify-content: center;
margin-top: 0.5rem;
}
:deep(.modal-actions button) {
padding: 1rem 2.2rem;
border-radius: 12px;
border: 0;
cursor: pointer;
font-weight: 700;
font-size: 1.25rem;
transition: background 0.18s;
min-width: 120px;
}
@media (max-width: 480px) {
:deep(.modal-actions) {
gap: 1.2rem;
}
:deep(.modal-actions button) {
padding: 0.8rem 1.2rem;
font-size: 1.05rem;
min-width: 90px;
}
}
</style> </style>

View File

@@ -0,0 +1,32 @@
<script setup lang="ts">
defineProps<{
loading: boolean
error: string | null
}>()
</script>
<template>
<div v-if="loading" class="loading">Loading...</div>
<div v-else-if="error" class="error">Error: {{ error }}</div>
</template>
<style scoped>
.loading {
color: var(--loading-color);
min-height: 200px;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
}
.error {
color: var(--error-color);
min-height: 100px;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
padding: 1rem;
}
</style>

View File

@@ -1,171 +0,0 @@
<script setup lang="ts">
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'
const imageCacheName = 'images-v1'
const props = defineProps<{
title: string
taskIds: string[]
childId: string | number | null
isParentAuthenticated: boolean
filterType?: number | null
}>()
const emit = defineEmits<{
(e: 'trigger-task', task: Task): void
}>()
const tasks = ref<Task[]>([])
const loading = ref(true)
const error = ref<string | null>(null)
const scrollWrapper = ref<HTMLDivElement | null>(null)
const taskRefs = ref<Record<string, HTMLElement | null>>({})
const lastCenteredTaskId = ref<string | null>(null)
const readyTaskId = ref<string | null>(null)
const fetchTasks = async () => {
const taskPromises = props.taskIds.map((id) =>
fetch(`/api/task/${id}`).then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json()
}),
)
try {
const results = await Promise.all(taskPromises)
tasks.value = results
// Fetch images for each task (uses shared imageCache)
await Promise.all(tasks.value.map(fetchImage))
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to fetch tasks'
console.error('Error fetching tasks:', err)
} finally {
loading.value = false
}
}
const fetchImage = async (task: Task) => {
if (!task.image_id) {
console.log(`No image ID for task: ${task.id}`)
return
}
try {
const url = await getCachedImageUrl(task.image_id, imageCacheName)
task.image_url = url
} catch (err) {
console.error('Error fetching image for task', task.id, err)
}
}
const centerTask = async (taskId: string) => {
await nextTick()
const wrapper = scrollWrapper.value
const card = taskRefs.value[taskId]
if (wrapper && card) {
const wrapperRect = wrapper.getBoundingClientRect()
const cardRect = card.getBoundingClientRect()
const wrapperScrollLeft = wrapper.scrollLeft
const cardCenter = cardRect.left + cardRect.width / 2
const wrapperCenter = wrapperRect.left + wrapperRect.width / 2
const scrollOffset = cardCenter - wrapperCenter
wrapper.scrollTo({
left: wrapperScrollLeft + scrollOffset,
behavior: 'smooth',
})
}
}
const triggerTask = (taskId: string) => {
const task = tasks.value.find((t) => t.id === taskId)
if (task) emit('trigger-task', task)
}
const handleTaskClick = async (taskId: string) => {
await nextTick()
const wrapper = scrollWrapper.value
const card = taskRefs.value[taskId]
if (!wrapper || !card) return
const wrapperRect = wrapper.getBoundingClientRect()
const cardRect = card.getBoundingClientRect()
const cardCenter = cardRect.left + cardRect.width / 2
const cardFullyVisible = cardCenter >= wrapperRect.left && cardCenter <= wrapperRect.right
if (!cardFullyVisible || lastCenteredTaskId.value !== taskId) {
// Center the task, but don't trigger
await centerTask(taskId)
lastCenteredTaskId.value = taskId
readyTaskId.value = taskId
return
}
// If already centered and visible, emit to parent
triggerTask(taskId)
readyTaskId.value = null
}
const filteredTasks = computed(() => {
if (props.filterType == 1) {
return tasks.value.filter((t) => t.is_good)
} else if (props.filterType == 2) {
return tasks.value.filter((t) => !t.is_good)
}
return tasks.value
})
watch(
() => props.taskIds,
(newTaskIds) => {
if (newTaskIds && newTaskIds.length > 0) {
fetchTasks()
} else {
tasks.value = []
loading.value = false
}
},
{ immediate: true },
)
// revoke all created object URLs when component unmounts
onBeforeUnmount(() => {
revokeAllImageUrls()
})
</script>
<template>
<div class="child-list-container">
<h3>{{ title }}</h3>
<div v-if="loading" class="loading">Loading tasks...</div>
<div v-else-if="error" class="error">Error: {{ error }}</div>
<div v-else-if="filteredTasks.length === 0" class="empty">No {{ title }}</div>
<div v-else class="scroll-wrapper" ref="scrollWrapper">
<div class="item-scroll">
<div
v-for="task in filteredTasks"
:key="task.id"
class="item-card"
:class="{ good: task.is_good, bad: !task.is_good, ready: readyTaskId === task.id }"
:ref="(el) => (taskRefs[task.id] = el)"
@click="() => handleTaskClick(task.id)"
>
<div class="item-name">{{ task.name }}</div>
<img v-if="task.image_url" :src="task.image_url" alt="Task Image" class="task-image" />
<div
class="item-points"
:class="{ 'good-points': task.is_good, 'bad-points': !task.is_good }"
>
{{ task.is_good ? task.points : -task.points }} Points
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped></style>

View File

@@ -1,4 +1,4 @@
import '@/assets/global.css' import '@/assets/colors.css'
import { createApp } from 'vue' import { createApp } from 'vue'
import App from './App.vue' import App from './App.vue'
import router from './router' import router from './router'