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
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:
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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;
|
||||||
100
frontend/vue-app/src/assets/styles.css
Normal file
100
frontend/vue-app/src/assets/styles.css
Normal 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);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
32
frontend/vue-app/src/components/shared/StatusMessage.vue
Normal file
32
frontend/vue-app/src/components/shared/StatusMessage.vue
Normal 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>
|
||||||
@@ -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>
|
|
||||||
@@ -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'
|
||||||
|
|||||||
Reference in New Issue
Block a user