All checks were successful
Chore App Build and Push Docker Images / build-and-push (push) Successful in 1m23s
- Remove OverrideEditModal.spec.ts test file. - Update ParentPinSetup.vue to handle Enter key for code and PIN inputs. - Modify ChildEditView.vue to add maxlength for age input. - Enhance ChildView.vue with reward confirmation and cancellation dialogs. - Update ParentView.vue to handle pending rewards and confirm edits. - Revise PendingRewardDialog.vue to accept a dynamic message prop. - Expand ChildView.spec.ts to cover reward dialog interactions. - Add tests for ParentView.vue to validate pending reward handling. - Update UserProfile.vue to simplify button styles. - Adjust RewardView.vue to improve delete confirmation handling. - Modify ChildrenListView.vue to clarify child creation instructions. - Refactor EntityEditForm.vue to improve input handling and focus management. - Enhance ItemList.vue to support item selection. - Update LoginButton.vue to focus PIN input on error. - Change ScrollingList.vue empty state color for better visibility. - Remove capture attribute from ImagePicker.vue file input. - Update router/index.ts to redirect logged-in users from auth routes. - Add authGuard.spec.ts to test router authentication logic.
389 lines
9.5 KiB
Vue
389 lines
9.5 KiB
Vue
<script setup lang="ts">
|
|
import { ref, watch, onBeforeUnmount, nextTick, computed } from 'vue'
|
|
import { getCachedImageUrl, revokeAllImageUrls } from '@/common/imageCache'
|
|
|
|
const props = defineProps<{
|
|
title: string
|
|
fetchBaseUrl: string
|
|
ids?: readonly string[]
|
|
itemKey: string
|
|
imageFields?: readonly string[]
|
|
isParentAuthenticated?: boolean
|
|
filterFn?: (item: any) => boolean
|
|
getItemClass?: (item: any) => string | string[] | Record<string, boolean>
|
|
enableEdit?: boolean
|
|
childId?: string
|
|
readyItemId?: string | null
|
|
}>()
|
|
|
|
// Compute the fetch URL with ids if present
|
|
const fetchUrl = computed(() => {
|
|
if (props.ids && props.ids.length > 0) {
|
|
const separator = props.fetchBaseUrl.includes('?') ? '&' : '?'
|
|
return `${props.fetchBaseUrl}${separator}ids=${props.ids.join(',')}`
|
|
}
|
|
return props.fetchBaseUrl
|
|
})
|
|
|
|
const emit = defineEmits<{
|
|
(e: 'trigger-item', item: any): void
|
|
(e: 'edit-item', item: any): void
|
|
(e: 'item-ready', itemId: string): void
|
|
}>()
|
|
|
|
const items = ref<any[]>([])
|
|
const loading = ref(true)
|
|
const error = ref<string | null>(null)
|
|
const scrollWrapper = ref<HTMLDivElement | null>(null)
|
|
const itemRefs = ref<Record<string, HTMLElement | Element | null>>({})
|
|
const lastCenteredItemId = ref<string | null>(null)
|
|
|
|
const fetchItems = async () => {
|
|
loading.value = true
|
|
error.value = null
|
|
|
|
try {
|
|
const resp = await fetch(fetchUrl.value)
|
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
|
const data = await resp.json()
|
|
// Try to use 'tasks', 'reward_status', or 'items' as fallback
|
|
let itemList = data[props.itemKey || 'items'] || []
|
|
if (props.filterFn) itemList = itemList.filter(props.filterFn)
|
|
items.value = itemList
|
|
// Fetch images for each item
|
|
await Promise.all(
|
|
itemList.map(async (item: any) => {
|
|
if (props.imageFields) {
|
|
for (const field of props.imageFields) {
|
|
if (item[field]) {
|
|
try {
|
|
item[`${field.replace('_id', '_url')}`] = await getCachedImageUrl(item[field])
|
|
} catch {
|
|
item[`${field.replace('_id', '_url')}`] = null
|
|
}
|
|
}
|
|
}
|
|
} else if (item.image_id) {
|
|
try {
|
|
item.image_url = await getCachedImageUrl(item.image_id)
|
|
} catch {
|
|
console.error('Error fetching image for item', item.id)
|
|
item.image_url = null
|
|
}
|
|
}
|
|
}),
|
|
)
|
|
} catch (err) {
|
|
error.value = err instanceof Error ? err.message : 'Failed to fetch items'
|
|
items.value = []
|
|
console.error('Error fetching items:', err)
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
async function refresh() {
|
|
await fetchItems()
|
|
loading.value = false
|
|
}
|
|
|
|
defineExpose({
|
|
refresh,
|
|
items,
|
|
})
|
|
|
|
const centerItem = async (itemId: string) => {
|
|
await nextTick()
|
|
const wrapper = scrollWrapper.value
|
|
const card = itemRefs.value[itemId]
|
|
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 handleClicked = async (item: any) => {
|
|
await nextTick()
|
|
const wrapper = scrollWrapper.value
|
|
const card = itemRefs.value[item.id]
|
|
if (!wrapper || !card) return
|
|
|
|
// If this item is already ready (has edit button showing)
|
|
if (props.readyItemId === item.id) {
|
|
// Second click - trigger the item and reset
|
|
emit(
|
|
'trigger-item',
|
|
items.value.find((i) => i.id === item.id),
|
|
)
|
|
emit('item-ready', '')
|
|
lastCenteredItemId.value = null
|
|
return
|
|
}
|
|
|
|
// First click or different item clicked
|
|
// Check if item needs to be centered
|
|
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) {
|
|
// Center the item first
|
|
await centerItem(item.id)
|
|
}
|
|
|
|
// Mark this item as ready (show edit button)
|
|
lastCenteredItemId.value = item.id
|
|
emit('item-ready', item.id)
|
|
}
|
|
|
|
const handleEditClick = (item: any, event: Event) => {
|
|
event.stopPropagation()
|
|
emit('edit-item', item)
|
|
// Reset the 2-step process after opening edit modal
|
|
emit('item-ready', '')
|
|
lastCenteredItemId.value = null
|
|
}
|
|
|
|
watch(
|
|
() => [props.ids],
|
|
() => {
|
|
fetchItems()
|
|
},
|
|
{ immediate: true },
|
|
)
|
|
|
|
onBeforeUnmount(() => {
|
|
revokeAllImageUrls()
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div class="child-list-container">
|
|
<h3>{{ title }}</h3>
|
|
|
|
<div v-if="loading" class="loading">Loading...</div>
|
|
<div v-else-if="error" class="error">Error: {{ error }}</div>
|
|
<div v-else-if="items.length === 0" class="empty">No {{ title }}</div>
|
|
<div v-else class="scroll-wrapper" ref="scrollWrapper">
|
|
<div class="item-scroll">
|
|
<div
|
|
v-for="item in items"
|
|
:key="item.id"
|
|
:class="['item-card', props.getItemClass?.(item)]"
|
|
:ref="(el) => (itemRefs[item.id] = el)"
|
|
@click.stop="handleClicked(item)"
|
|
>
|
|
<button
|
|
v-if="enableEdit && readyItemId === item.id"
|
|
class="edit-button"
|
|
@click="handleEditClick(item, $event)"
|
|
title="Edit custom value"
|
|
>
|
|
<img src="/edit.png" alt="Edit" />
|
|
</button>
|
|
<span
|
|
v-if="
|
|
isParentAuthenticated && item.custom_value !== undefined && item.custom_value !== null
|
|
"
|
|
class="override-badge"
|
|
>⭐</span
|
|
>
|
|
<slot name="item" :item="item">
|
|
<div class="item-name">{{ item.name }}</div>
|
|
</slot>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.child-list-container {
|
|
background: var(--child-list-bg, rgba(255, 255, 255, 0.1));
|
|
border-radius: 12px;
|
|
padding: 1rem;
|
|
color: var(--child-list-title-color, #fff);
|
|
width: 100%;
|
|
box-sizing: border-box;
|
|
}
|
|
.scroll-wrapper {
|
|
overflow-x: auto;
|
|
overflow-y: hidden;
|
|
scroll-behavior: smooth;
|
|
width: 100%;
|
|
-webkit-overflow-scrolling: touch;
|
|
}
|
|
|
|
.scroll-wrapper::-webkit-scrollbar {
|
|
height: 8px;
|
|
}
|
|
|
|
.scroll-wrapper::-webkit-scrollbar-track {
|
|
background: var(--child-list-scrollbar-track, rgba(255, 255, 255, 0.05));
|
|
border-radius: 10px;
|
|
}
|
|
|
|
.scroll-wrapper::-webkit-scrollbar-thumb {
|
|
background: var(
|
|
--child-list-scrollbar-thumb,
|
|
linear-gradient(180deg, rgba(102, 126, 234, 0.8), rgba(118, 75, 162, 0.8))
|
|
);
|
|
border-radius: 10px;
|
|
border: var(--child-list-scrollbar-thumb-border, 2px solid rgba(255, 255, 255, 0.08));
|
|
}
|
|
|
|
.scroll-wrapper::-webkit-scrollbar-thumb:hover {
|
|
background: var(
|
|
--child-list-scrollbar-thumb-hover,
|
|
linear-gradient(180deg, rgba(102, 126, 234, 1), rgba(118, 75, 162, 1))
|
|
);
|
|
box-shadow: 0 0 8px rgba(102, 126, 234, 0.4);
|
|
}
|
|
|
|
.item-scroll {
|
|
display: flex;
|
|
gap: 0.75rem;
|
|
min-width: min-content;
|
|
padding: 0.5rem 0;
|
|
}
|
|
|
|
/* Fallback for browsers that don't support flex gap */
|
|
.item-card + .item-card {
|
|
margin-left: 0.75rem;
|
|
}
|
|
|
|
.item-card {
|
|
position: relative;
|
|
background: var(--item-card-bg, rgba(255, 255, 255, 0.12));
|
|
border-radius: 8px;
|
|
padding: 0.75rem;
|
|
min-width: 140px;
|
|
max-width: 220px;
|
|
width: 100%;
|
|
text-align: center;
|
|
flex-shrink: 0;
|
|
transition: transform 0.18s ease;
|
|
border: var(--item-card-border, 1px solid rgba(255, 255, 255, 0.08));
|
|
box-sizing: border-box;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
cursor: pointer;
|
|
user-select: none; /* Prevent image selection */
|
|
}
|
|
|
|
.item-card:hover {
|
|
transform: translateY(-4px);
|
|
box-shadow: var(--item-card-hover-shadow, 0 8px 20px rgba(0, 0, 0, 0.12));
|
|
}
|
|
|
|
.edit-button {
|
|
position: absolute;
|
|
top: 4px;
|
|
right: 4px;
|
|
width: 34px;
|
|
height: 34px;
|
|
border: none;
|
|
background-color: var(--btn-primary);
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 0;
|
|
transition:
|
|
opacity 0.2s,
|
|
transform 0.1s;
|
|
z-index: 10;
|
|
}
|
|
|
|
.edit-button:hover {
|
|
opacity: 0.9;
|
|
transform: scale(1.05);
|
|
}
|
|
|
|
.edit-button img {
|
|
width: 20px;
|
|
height: 20px;
|
|
filter: brightness(0) invert(1);
|
|
}
|
|
|
|
.override-badge {
|
|
position: absolute;
|
|
top: 4px;
|
|
left: 4px;
|
|
font-size: 12px;
|
|
z-index: 5;
|
|
}
|
|
|
|
@keyframes ready-glow {
|
|
0% {
|
|
box-shadow: 0 0 0 0 #667eea00;
|
|
border-color: inherit;
|
|
}
|
|
100% {
|
|
box-shadow: var(--item-card-ready-shadow, 0 0 0 3px #667eea88, 0 0 12px #667eea44);
|
|
border-color: var(--item-card-ready-border, #667eea);
|
|
}
|
|
}
|
|
|
|
:deep(.item-name) {
|
|
font-size: 0.95rem;
|
|
font-weight: 700;
|
|
margin-bottom: 0.4rem;
|
|
color: var(--item-name-color, #fff);
|
|
line-height: 1.2;
|
|
word-break: break-word;
|
|
}
|
|
|
|
/* Image styling */
|
|
:deep(.item-image) {
|
|
width: 70px;
|
|
height: 70px;
|
|
object-fit: cover;
|
|
border-radius: 6px;
|
|
margin: 0 auto 0.4rem auto;
|
|
display: block;
|
|
}
|
|
|
|
/* Mobile tweaks */
|
|
@media (max-width: 480px) {
|
|
.item-card {
|
|
min-width: 110px;
|
|
max-width: 150px;
|
|
padding: 0.6rem;
|
|
}
|
|
:deep(.item-name) {
|
|
font-size: 0.86rem;
|
|
}
|
|
:deep(.item-image) {
|
|
width: 50px;
|
|
height: 50px;
|
|
margin: 0 auto 0.3rem auto;
|
|
}
|
|
.scroll-wrapper::-webkit-scrollbar {
|
|
height: 10px;
|
|
}
|
|
.scroll-wrapper::-webkit-scrollbar-thumb {
|
|
border-width: 1px;
|
|
}
|
|
}
|
|
|
|
.loading,
|
|
.error,
|
|
.empty {
|
|
text-align: center;
|
|
padding: 2rem 0;
|
|
color: #d6d6d6;
|
|
}
|
|
</style>
|