Files
chore/frontend/vue-app/src/components/shared/ScrollingList.vue
Ryan Kegel 65e987ceb6
Some checks failed
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 1m11s
feat: add delay before showing dialogs and enhance item card styles for better user feedback
2026-02-27 14:05:09 -05:00

407 lines
9.9 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),
{ 'item-ready': props.readyItemId === item.id },
]"
: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 */
-webkit-tap-highlight-color: transparent; /* Suppress native mobile tap flash */
}
/* Only apply hover lift on true pointer (non-touch) devices */
@media (hover: hover) {
.item-card:hover {
transform: translateY(-4px);
box-shadow: var(--item-card-hover-shadow, 0 8px 20px rgba(0, 0, 0, 0.12));
}
}
/* Brief press feedback on touch devices */
.item-card:active {
transform: scale(0.97);
transition: transform 0.08s ease;
}
.item-card.item-ready {
animation: ready-glow 0.25s ease forwards;
}
.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>