All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m43s
364 lines
8.3 KiB
Vue
364 lines
8.3 KiB
Vue
<script setup lang="ts">
|
|
import { useRouter, useRoute } from 'vue-router'
|
|
import { computed, ref, onMounted, onUnmounted } from 'vue'
|
|
import LoginButton from '../components/shared/LoginButton.vue'
|
|
import { eventBus } from '@/common/eventBus'
|
|
import type {
|
|
Event,
|
|
ChildRewardRequestEventPayload,
|
|
ChildChoreConfirmationPayload,
|
|
} from '@/common/models'
|
|
|
|
const router = useRouter()
|
|
const route = useRoute()
|
|
|
|
const handleBack = () => {
|
|
if (window.history.length > 1) {
|
|
router.back()
|
|
} else {
|
|
router.push('/child')
|
|
}
|
|
}
|
|
|
|
// Hide view-selector if on ParentPinSetup (PIN setup flow)
|
|
const hideViewSelector = computed(() => route.name === 'ParentPinSetup')
|
|
|
|
const showBack = computed(
|
|
() =>
|
|
!(
|
|
route.path === '/parent' ||
|
|
route.name === 'TaskView' ||
|
|
route.name === 'ChoreView' ||
|
|
route.name === 'KindnessView' ||
|
|
route.name === 'PenaltyView' ||
|
|
route.name === 'RewardView' ||
|
|
route.name === 'NotificationView'
|
|
),
|
|
)
|
|
|
|
// Notification badge count
|
|
const notificationCount = ref(0)
|
|
|
|
async function fetchNotificationCount() {
|
|
try {
|
|
const resp = await fetch('/api/pending-confirmations')
|
|
if (resp.ok) {
|
|
const data = await resp.json()
|
|
notificationCount.value = Array.isArray(data.confirmations) ? data.confirmations.length : 0
|
|
}
|
|
} catch {
|
|
notificationCount.value = 0
|
|
}
|
|
}
|
|
|
|
function handleRewardRequestBadge(event: Event) {
|
|
const payload = event.payload as ChildRewardRequestEventPayload
|
|
if (['CREATED', 'CANCELLED', 'GRANTED'].includes(payload.operation)) {
|
|
fetchNotificationCount()
|
|
}
|
|
}
|
|
|
|
function handleChoreConfirmationBadge(event: Event) {
|
|
const payload = event.payload as ChildChoreConfirmationPayload
|
|
if (['CONFIRMED', 'APPROVED', 'REJECTED', 'CANCELLED'].includes(payload.operation)) {
|
|
fetchNotificationCount()
|
|
}
|
|
}
|
|
|
|
// Version fetching
|
|
const appVersion = ref('')
|
|
|
|
onMounted(async () => {
|
|
await fetchNotificationCount()
|
|
eventBus.on('child_reward_request', handleRewardRequestBadge)
|
|
eventBus.on('child_chore_confirmation', handleChoreConfirmationBadge)
|
|
|
|
try {
|
|
const resp = await fetch('/api/version')
|
|
if (resp.ok) {
|
|
const data = await resp.json()
|
|
appVersion.value = data.version || ''
|
|
}
|
|
} catch (e) {
|
|
appVersion.value = ''
|
|
}
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
eventBus.off('child_reward_request', handleRewardRequestBadge)
|
|
eventBus.off('child_chore_confirmation', handleChoreConfirmationBadge)
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div class="layout-root">
|
|
<header class="topbar">
|
|
<div class="end-button-container">
|
|
<button v-show="showBack" class="back-btn" @click="handleBack" tabindex="0">← Back</button>
|
|
</div>
|
|
<nav v-if="!hideViewSelector" class="view-selector">
|
|
<button
|
|
:class="{
|
|
active: [
|
|
'ParentChildrenListView',
|
|
'ParentView',
|
|
'ChildEditView',
|
|
'CreateChild',
|
|
'ChoreAssignView',
|
|
'KindnessAssignView',
|
|
'PenaltyAssignView',
|
|
'RewardAssignView',
|
|
].includes(String(route.name)),
|
|
}"
|
|
@click="router.push({ name: 'ParentChildrenListView' })"
|
|
aria-label="Children"
|
|
title="Children"
|
|
>
|
|
<img src="/nav/children.png" class="nav-icon" alt="Children" />
|
|
</button>
|
|
<button
|
|
:class="{
|
|
active: [
|
|
'TaskView',
|
|
'ChoreView',
|
|
'KindnessView',
|
|
'PenaltyView',
|
|
'EditChore',
|
|
'CreateChore',
|
|
'EditKindness',
|
|
'CreateKindness',
|
|
'EditPenalty',
|
|
'CreatePenalty',
|
|
].includes(String(route.name)),
|
|
}"
|
|
@click="router.push({ name: 'ChoreView' })"
|
|
aria-label="Tasks"
|
|
title="Tasks"
|
|
>
|
|
<img src="/nav/chores.png" class="nav-icon" alt="Tasks" />
|
|
</button>
|
|
<button
|
|
:class="{
|
|
active: ['RewardView', 'EditReward', 'CreateReward'].includes(String(route.name)),
|
|
}"
|
|
@click="router.push({ name: 'RewardView' })"
|
|
aria-label="Rewards"
|
|
title="Rewards"
|
|
>
|
|
<img src="/nav/rewards.png" class="nav-icon" alt="Rewards" />
|
|
</button>
|
|
<button
|
|
:class="{ active: ['NotificationView'].includes(String(route.name)) }"
|
|
@click="router.push({ name: 'NotificationView' })"
|
|
aria-label="Notifications"
|
|
title="Notifications"
|
|
>
|
|
<span class="nav-icon-wrap">
|
|
<img src="/nav/notifications.png" class="nav-icon" alt="Notifications" />
|
|
<span v-if="notificationCount > 0" class="notification-badge"></span>
|
|
</span>
|
|
</button>
|
|
</nav>
|
|
<div v-else class="spacer"></div>
|
|
<div class="end-button-container">
|
|
<LoginButton />
|
|
</div>
|
|
</header>
|
|
|
|
<main class="main-content">
|
|
<router-view :key="$route.fullPath" />
|
|
</main>
|
|
|
|
<div v-if="appVersion" class="app-version">v{{ appVersion }}</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.layout-root {
|
|
width: 100%;
|
|
min-height: 100vh;
|
|
height: 100vh;
|
|
display: flex;
|
|
flex-direction: column;
|
|
padding: 0;
|
|
background: var(--header-bg, linear-gradient(135deg, #667eea 0%, #764ba2 100%));
|
|
}
|
|
|
|
.topbar {
|
|
display: flex;
|
|
align-items: stretch;
|
|
padding: 0;
|
|
height: 48px;
|
|
width: 100%;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
.end-button-container {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 65px;
|
|
min-width: 65px;
|
|
max-width: 65px;
|
|
height: 48px;
|
|
min-height: 48px;
|
|
max-height: 48px;
|
|
margin-left: 5px;
|
|
margin-right: 5px;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
.back-btn {
|
|
width: 65px;
|
|
min-width: 65px;
|
|
max-width: 65px;
|
|
height: 48px;
|
|
min-height: 48px;
|
|
max-height: 48px;
|
|
margin: 0;
|
|
background: var(--button-bg, #fff);
|
|
border: 0;
|
|
border-radius: 8px 8px 0 0;
|
|
cursor: pointer;
|
|
color: var(--button-text, #667eea);
|
|
font-weight: 600;
|
|
font-size: 0.8rem;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
box-sizing: border-box;
|
|
overflow: hidden;
|
|
padding: 0;
|
|
transition:
|
|
background 0.18s,
|
|
color 0.18s;
|
|
}
|
|
|
|
.spacer {
|
|
flex: 1 1 auto;
|
|
height: 100%;
|
|
display: flex;
|
|
align-items: center;
|
|
}
|
|
|
|
@media (max-width: 480px) {
|
|
.back-btn {
|
|
font-size: 0.7rem;
|
|
}
|
|
}
|
|
|
|
.view-selector {
|
|
height: 100%;
|
|
display: flex;
|
|
align-items: stretch;
|
|
flex: 1 1 0;
|
|
justify-content: center;
|
|
min-width: 0;
|
|
z-index: 1;
|
|
}
|
|
|
|
.view-selector button {
|
|
height: 100%;
|
|
display: flex;
|
|
align-items: center;
|
|
background: var(--button-bg-inactive, #f3f3f3);
|
|
color: var(--button-text);
|
|
border: 0;
|
|
border-radius: 8px 8px 0 0;
|
|
padding: 5px 10px;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
transition:
|
|
background 0.18s,
|
|
color 0.18s;
|
|
font-size: 1rem;
|
|
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.08);
|
|
min-width: 0;
|
|
}
|
|
|
|
.view-selector button.active {
|
|
color: var(--button-active-text);
|
|
background: rgba(255, 255, 255, 1);
|
|
box-shadow:
|
|
inset 3px 3px 0 rgba(255, 255, 255, 0.8),
|
|
inset -3px -3px 0 rgba(0, 0, 0, 0.2);
|
|
}
|
|
|
|
.nav-icon {
|
|
width: 40px;
|
|
height: 40px;
|
|
object-fit: contain;
|
|
opacity: 0.55;
|
|
transition: opacity 0.18s;
|
|
display: block;
|
|
}
|
|
|
|
.nav-icon-wrap {
|
|
position: relative;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.notification-badge {
|
|
position: absolute;
|
|
top: -3px;
|
|
right: -3px;
|
|
width: 8px;
|
|
height: 8px;
|
|
background: var(--btn-danger, #ef4444);
|
|
border-radius: 50%;
|
|
border: 1.5px solid var(--button-bg, #fff);
|
|
pointer-events: none;
|
|
}
|
|
|
|
.view-selector button.active .nav-icon,
|
|
.view-selector button:hover .nav-icon {
|
|
opacity: 1;
|
|
}
|
|
|
|
.view-selector button:hover:not(.active) {
|
|
background: var(--button-hover-bg);
|
|
}
|
|
|
|
@media (max-width: 480px) {
|
|
.view-selector button {
|
|
padding: 0.45rem 0.75rem;
|
|
font-size: 0.85rem;
|
|
}
|
|
}
|
|
|
|
.main-content {
|
|
flex: 1 1 auto;
|
|
width: 100%;
|
|
justify-content: center;
|
|
align-items: flex-start;
|
|
box-sizing: border-box;
|
|
min-height: 0;
|
|
height: 0;
|
|
overflow: hidden;
|
|
overflow-y: visible;
|
|
padding: 1rem;
|
|
}
|
|
|
|
.app-version {
|
|
position: fixed;
|
|
right: 18px;
|
|
bottom: 12px;
|
|
font-size: 0.92rem;
|
|
color: var(--app-version, #cbd5e1);
|
|
opacity: 0.85;
|
|
z-index: 100;
|
|
pointer-events: none;
|
|
user-select: none;
|
|
font-family: monospace;
|
|
}
|
|
|
|
.bevel-box {
|
|
background: #ccc;
|
|
box-shadow:
|
|
inset 2px 2px 0 rgba(255, 255, 255, 0.8),
|
|
inset -2px -2px 0 rgba(0, 0, 0, 0.2);
|
|
}
|
|
</style>
|