This commit is contained in:
530
frontend/vue-app/src/components/shared/ChildrenListView.vue
Normal file
530
frontend/vue-app/src/components/shared/ChildrenListView.vue
Normal file
@@ -0,0 +1,530 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { getCachedImageUrl, revokeAllImageUrls } from '../../common/imageCache'
|
||||
import { isParentAuthenticated } from '../../stores/auth'
|
||||
import { eventBus } from '@/common/eventBus'
|
||||
import type {
|
||||
Child,
|
||||
ChildModifiedEventPayload,
|
||||
ChildTaskTriggeredEventPayload,
|
||||
ChildRewardTriggeredEventPayload,
|
||||
Event,
|
||||
} from '@/common/models'
|
||||
import MessageBlock from '@/components/shared/MessageBlock.vue'
|
||||
import '@/assets/button-shared.css'
|
||||
|
||||
import FloatingActionButton from '../shared/FloatingActionButton.vue'
|
||||
import DeleteModal from '../shared/DeleteModal.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const children = ref<Child[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
const images = ref<Map<string, string>>(new Map()) // Store image URLs
|
||||
|
||||
const imageCacheName = 'images-v1'
|
||||
|
||||
// UI state for kebab menus & delete confirmation
|
||||
const activeMenuFor = ref<string | number | null>(null) // which child card shows menu
|
||||
const confirmDeleteVisible = ref(false)
|
||||
const deletingChildId = ref<string | number | null>(null)
|
||||
const deleting = ref(false)
|
||||
|
||||
const openChildEditor = (child: Child, evt?: Event) => {
|
||||
evt?.stopPropagation()
|
||||
router.push({ name: 'ChildEditView', params: { id: child.id } })
|
||||
}
|
||||
|
||||
async function handleChildModified(event: Event) {
|
||||
const payload = event.payload as ChildModifiedEventPayload
|
||||
const childId = payload.child_id
|
||||
|
||||
switch (payload.operation) {
|
||||
case 'DELETE':
|
||||
children.value = children.value.filter((c) => c.id !== childId)
|
||||
break
|
||||
|
||||
case 'ADD':
|
||||
try {
|
||||
const list = await fetchChildren()
|
||||
children.value = list
|
||||
} catch (err) {
|
||||
console.warn('Failed to fetch children after ADD operation:', err)
|
||||
}
|
||||
break
|
||||
|
||||
case 'EDIT':
|
||||
try {
|
||||
const list = await fetchChildren()
|
||||
const updatedChild = list.find((c) => c.id === childId)
|
||||
if (updatedChild) {
|
||||
const idx = children.value.findIndex((c) => c.id === childId)
|
||||
if (idx !== -1) {
|
||||
children.value[idx] = updatedChild
|
||||
} else {
|
||||
console.warn(`EDIT operation: child with id ${childId} not found in current list.`)
|
||||
}
|
||||
} else {
|
||||
console.warn(
|
||||
`EDIT operation: updated child with id ${childId} not found in fetched list.`,
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to fetch children after EDIT operation:', err)
|
||||
}
|
||||
break
|
||||
|
||||
default:
|
||||
console.warn(`Unknown operation: ${payload.operation}`)
|
||||
}
|
||||
}
|
||||
|
||||
function handleChildTaskTriggered(event: Event) {
|
||||
const payload = event.payload as ChildTaskTriggeredEventPayload
|
||||
const childId = payload.child_id
|
||||
const child = children.value.find((c) => c.id === childId)
|
||||
if (child) {
|
||||
child.points = payload.points
|
||||
} else {
|
||||
console.warn(`Child with id ${childId} not found when updating points.`)
|
||||
}
|
||||
}
|
||||
|
||||
function handleChildRewardTriggered(event: Event) {
|
||||
const payload = event.payload as ChildRewardTriggeredEventPayload
|
||||
const childId = payload.child_id
|
||||
const child = children.value.find((c) => c.id === childId)
|
||||
if (child) {
|
||||
child.points = payload.points
|
||||
} else {
|
||||
console.warn(`Child with id ${childId} not found when updating points.`)
|
||||
}
|
||||
}
|
||||
|
||||
// points update state
|
||||
const updatingPointsFor = ref<string | number | null>(null)
|
||||
|
||||
const fetchImage = async (imageId: string) => {
|
||||
try {
|
||||
const url = await getCachedImageUrl(imageId, imageCacheName)
|
||||
images.value.set(imageId, url)
|
||||
} catch (err) {
|
||||
console.warn('Failed to load child image', imageId, err)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchChildren = async (): Promise<Child[]> => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
images.value.clear()
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/child/list')
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
const data = await response.json()
|
||||
const childList = data.children || []
|
||||
|
||||
// Fetch images for each child (shared cache util)
|
||||
await Promise.all(
|
||||
childList.map((child) => {
|
||||
if (child.image_id) {
|
||||
return fetchImage(child.image_id)
|
||||
}
|
||||
return Promise.resolve()
|
||||
}),
|
||||
)
|
||||
return childList
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to fetch children'
|
||||
console.error('Error fetching children:', err)
|
||||
return []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const createChild = () => {
|
||||
router.push({ name: 'CreateChild' })
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
eventBus.on('child_modified', handleChildModified)
|
||||
eventBus.on('child_task_triggered', handleChildTaskTriggered)
|
||||
eventBus.on('child_reward_triggered', handleChildRewardTriggered)
|
||||
|
||||
const listPromise = fetchChildren()
|
||||
listPromise.then((list) => {
|
||||
children.value = list
|
||||
})
|
||||
// listen for outside clicks to auto-close any open kebab menu
|
||||
document.addEventListener('click', onDocClick, true)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
eventBus.off('child_modified', handleChildModified)
|
||||
eventBus.off('child_task_triggered', handleChildTaskTriggered)
|
||||
eventBus.off('child_reward_triggered', handleChildRewardTriggered)
|
||||
})
|
||||
|
||||
const shouldIgnoreNextCardClick = ref(false)
|
||||
|
||||
const onDocClick = (e: MouseEvent) => {
|
||||
if (activeMenuFor.value !== null) {
|
||||
const path = (e.composedPath && e.composedPath()) || (e as any).path || []
|
||||
const clickedInsideKebab = path.some((node: unknown) => {
|
||||
if (!(node instanceof HTMLElement)) return false
|
||||
return (
|
||||
node.classList.contains('kebab-wrap') ||
|
||||
node.classList.contains('kebab-btn') ||
|
||||
node.classList.contains('kebab-menu')
|
||||
)
|
||||
})
|
||||
if (!clickedInsideKebab) {
|
||||
activeMenuFor.value = null
|
||||
// If the click was on a card, set the flag to ignore the next card click
|
||||
if (
|
||||
path.some((node: unknown) => node instanceof HTMLElement && node.classList.contains('card'))
|
||||
) {
|
||||
shouldIgnoreNextCardClick.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const selectChild = (childId: string | number) => {
|
||||
if (shouldIgnoreNextCardClick.value) {
|
||||
shouldIgnoreNextCardClick.value = false
|
||||
return
|
||||
}
|
||||
if (activeMenuFor.value !== null) {
|
||||
// If kebab menu is open, ignore card clicks
|
||||
return
|
||||
}
|
||||
if (isParentAuthenticated.value) {
|
||||
router.push(`/parent/${childId}`)
|
||||
} else {
|
||||
router.push(`/child/${childId}`)
|
||||
}
|
||||
}
|
||||
|
||||
// kebab menu helpers
|
||||
const openMenu = (childId: string | number, evt?: Event) => {
|
||||
evt?.stopPropagation()
|
||||
activeMenuFor.value = childId
|
||||
}
|
||||
const closeMenu = () => {
|
||||
activeMenuFor.value = null
|
||||
}
|
||||
|
||||
// delete flow
|
||||
const askDelete = (childId: string | number, evt?: Event) => {
|
||||
evt?.stopPropagation()
|
||||
deletingChildId.value = childId
|
||||
confirmDeleteVisible.value = true
|
||||
closeMenu()
|
||||
}
|
||||
|
||||
const performDelete = async () => {
|
||||
if (!deletingChildId.value) return
|
||||
deleting.value = true
|
||||
try {
|
||||
const resp = await fetch(`/api/child/${deletingChildId.value}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Delete failed: ${resp.status}`)
|
||||
}
|
||||
// refresh list
|
||||
await fetchChildren()
|
||||
} catch (err) {
|
||||
console.error('Failed to delete child', deletingChildId.value, err)
|
||||
} finally {
|
||||
deleting.value = false
|
||||
confirmDeleteVisible.value = false
|
||||
deletingChildId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// Delete Points flow: set points to 0 via API and refresh points display
|
||||
const deletePoints = async (childId: string | number, evt?: Event) => {
|
||||
evt?.stopPropagation()
|
||||
closeMenu()
|
||||
updatingPointsFor.value = childId
|
||||
try {
|
||||
const resp = await fetch(`/api/child/${childId}/edit`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ points: 0 }),
|
||||
})
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Failed to update points: ${resp.status}`)
|
||||
}
|
||||
// no need to refresh since we update optimistically via eventBus
|
||||
} catch (err) {
|
||||
console.error('Failed to delete points for child', childId, err)
|
||||
} finally {
|
||||
updatingPointsFor.value = null
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', onDocClick, true)
|
||||
revokeAllImageUrls()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<MessageBlock v-if="children.length === 0" message="No children">
|
||||
<span v-if="!isParentAuthenticated">
|
||||
<button class="round-btn" @click="eventBus.emit('open-login')">Sign in</button> to create a
|
||||
child
|
||||
</span>
|
||||
<span v-else> <button class="round-btn" @click="createChild">Create</button> a child </span>
|
||||
</MessageBlock>
|
||||
|
||||
<div v-else-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<div v-else-if="error" class="error">Error: {{ error }}</div>
|
||||
|
||||
<div v-else class="grid">
|
||||
<div v-for="child in children" :key="child.id" class="card" @click="selectChild(child.id)">
|
||||
<!-- kebab menu shown only for authenticated parent -->
|
||||
<div v-if="isParentAuthenticated" class="kebab-wrap" @click.stop>
|
||||
<!-- kebab button -->
|
||||
<button
|
||||
class="kebab-btn"
|
||||
@mousedown.stop.prevent
|
||||
@click="openMenu(child.id, $event)"
|
||||
:aria-expanded="activeMenuFor === child.id ? 'true' : 'false'"
|
||||
aria-label="Options"
|
||||
>
|
||||
⋮
|
||||
</button>
|
||||
|
||||
<!-- menu items -->
|
||||
<div
|
||||
v-if="activeMenuFor === child.id"
|
||||
class="kebab-menu"
|
||||
@mousedown.stop.prevent
|
||||
@click.stop
|
||||
>
|
||||
<button
|
||||
class="menu-item"
|
||||
@mousedown.stop.prevent
|
||||
@click="openChildEditor(child, $event)"
|
||||
>
|
||||
Edit Child
|
||||
</button>
|
||||
<button
|
||||
class="menu-item"
|
||||
@mousedown.stop.prevent
|
||||
@click="deletePoints(child.id, $event)"
|
||||
:disabled="updatingPointsFor === child.id"
|
||||
>
|
||||
{{ updatingPointsFor === child.id ? 'Updating…' : 'Delete Points' }}
|
||||
</button>
|
||||
<button class="menu-item danger" @mousedown.stop.prevent @click="askDelete(child.id)">
|
||||
Delete Child
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<h2>{{ child.name }}</h2>
|
||||
<img
|
||||
v-if="images.get(child.image_id)"
|
||||
:src="images.get(child.image_id)"
|
||||
alt="Child Image"
|
||||
class="child-image"
|
||||
/>
|
||||
<p class="age">Age: {{ child.age }}</p>
|
||||
<p class="points">Points: {{ child.points ?? 0 }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DeleteModal
|
||||
:show="confirmDeleteVisible"
|
||||
message="Are you sure you want to delete this child?"
|
||||
@confirm="performDelete"
|
||||
@cancel="confirmDeleteVisible = false"
|
||||
/>
|
||||
|
||||
<FloatingActionButton
|
||||
v-if="isParentAuthenticated"
|
||||
aria-label="Add Child"
|
||||
@click="createChild"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 1.2rem 1.2rem;
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--card-bg);
|
||||
box-shadow: var(--card-shadow);
|
||||
border-radius: 12px;
|
||||
overflow: visible; /* allow menu to overflow */
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
cursor: pointer;
|
||||
position: relative; /* for kebab positioning */
|
||||
width: 250px;
|
||||
}
|
||||
|
||||
/* kebab button / menu (fixed-size button, absolutely positioned menu) */
|
||||
.kebab-wrap {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
z-index: 20;
|
||||
/* keep the wrapper only as a positioning context */
|
||||
}
|
||||
|
||||
.kebab-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
color: var(--kebab-icon-color);
|
||||
border-radius: 6px;
|
||||
box-sizing: border-box;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
/* consistent focus ring without changing layout */
|
||||
.kebab-btn:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.18);
|
||||
}
|
||||
|
||||
/* Menu overlays the card and does NOT alter flow */
|
||||
.kebab-menu {
|
||||
position: absolute;
|
||||
top: 44px;
|
||||
right: 0;
|
||||
margin: 0;
|
||||
min-width: 150px;
|
||||
background: var(--kebab-menu-bg);
|
||||
border: 1.5px solid var(--kebab-menu-border);
|
||||
box-shadow: var(--kebab-menu-shadow);
|
||||
backdrop-filter: blur(var(--kebab-menu-blur));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
padding: 1.1rem 0.9rem; /* Increase vertical padding for bigger touch area */
|
||||
background: transparent;
|
||||
border: 0;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
color: var(--menu-item-color);
|
||||
font-size: 1.1rem; /* Slightly larger text for readability */
|
||||
}
|
||||
|
||||
.menu-item + .menu-item {
|
||||
margin-top: 0.5rem; /* Add space between menu items */
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.menu-item {
|
||||
padding: 0.85rem 0.7rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.menu-item + .menu-item {
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-item:hover {
|
||||
background: var(--menu-item-hover-bg);
|
||||
}
|
||||
|
||||
.menu-item.danger {
|
||||
color: var(--menu-item-danger);
|
||||
}
|
||||
|
||||
/* card content */
|
||||
.card-content {
|
||||
padding: 1.5rem;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
font-size: 1.5rem;
|
||||
color: var(--card-title);
|
||||
margin-bottom: 0.5rem;
|
||||
word-break: break-word;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.age {
|
||||
font-size: 1.1rem;
|
||||
color: var(--age-color);
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.child-image {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
object-fit: cover;
|
||||
border-radius: 50%;
|
||||
margin: 0 auto 1rem auto;
|
||||
background: var(--child-image-bg);
|
||||
}
|
||||
|
||||
.points {
|
||||
font-size: 1.05rem;
|
||||
color: var(--points-color);
|
||||
margin-top: 0.4rem;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Loading, error, empty states */
|
||||
.loading,
|
||||
.empty {
|
||||
margin: 1.2rem 0;
|
||||
color: var(--list-loading-color);
|
||||
font-size: 1.15rem;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.error {
|
||||
color: var(--error);
|
||||
margin-top: 0.7rem;
|
||||
text-align: center;
|
||||
background: var(--error-bg);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
}
|
||||
.value {
|
||||
font-weight: 600;
|
||||
margin-left: 1rem;
|
||||
}
|
||||
</style>
|
||||
48
frontend/vue-app/src/components/shared/DeleteModal.vue
Normal file
48
frontend/vue-app/src/components/shared/DeleteModal.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<div class="modal-backdrop" v-if="show">
|
||||
<div class="modal">
|
||||
<p>{{ message }}</p>
|
||||
<div class="actions">
|
||||
<button @click="handleDelete" class="btn btn-danger" :disabled="deleting">
|
||||
{{ deleting ? 'Deleting' : 'Delete' }}
|
||||
</button>
|
||||
<button @click="handleCancel" class="btn btn-secondary" :disabled="deleting">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
message?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['confirm', 'cancel'])
|
||||
|
||||
const deleting = ref(false)
|
||||
|
||||
function handleDelete() {
|
||||
deleting.value = true
|
||||
emit('confirm')
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
if (!deleting.value) emit('cancel')
|
||||
}
|
||||
|
||||
// Reset deleting state when modal is closed
|
||||
watch(
|
||||
() => props.show,
|
||||
(val) => {
|
||||
if (!val) deleting.value = false
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@import '@/assets/modal.css';
|
||||
@import '@/assets/actions-shared.css';
|
||||
</style>
|
||||
6
frontend/vue-app/src/components/shared/ErrorMessage.vue
Normal file
6
frontend/vue-app/src/components/shared/ErrorMessage.vue
Normal file
@@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<div v-if="message" class="error-message" aria-live="polite">{{ message }}</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
defineProps<{ message: string }>()
|
||||
</script>
|
||||
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<button class="fab" @click="$emit('click')" :aria-label="ariaLabel">
|
||||
<svg width="28" height="28" viewBox="0 0 28 28" fill="none">
|
||||
<circle cx="14" cy="14" r="14" fill="#667eea" />
|
||||
<path d="M14 8v12M8 14h12" stroke="#fff" stroke-width="2" stroke-linecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import '@/assets/global.css'
|
||||
defineProps<{ ariaLabel?: string }>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fab {
|
||||
position: fixed;
|
||||
bottom: 2rem;
|
||||
right: 2rem;
|
||||
background: var(--fab-bg);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||
cursor: pointer;
|
||||
font-size: 24px;
|
||||
z-index: 1300;
|
||||
}
|
||||
|
||||
.fab:hover {
|
||||
background: var(--fab-hover-bg);
|
||||
}
|
||||
|
||||
.fab:active {
|
||||
background: var(--fab-active-bg);
|
||||
}
|
||||
svg {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
267
frontend/vue-app/src/components/shared/ItemList.vue
Normal file
267
frontend/vue-app/src/components/shared/ItemList.vue
Normal file
@@ -0,0 +1,267 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import { getCachedImageUrl } from '@/common/imageCache'
|
||||
|
||||
const props = defineProps<{
|
||||
fetchUrl: string
|
||||
itemKey: string
|
||||
itemFields: readonly string[]
|
||||
imageFields?: readonly string[]
|
||||
selectable?: boolean
|
||||
deletable?: boolean
|
||||
onClicked?: (item: any) => void
|
||||
onDelete?: (id: string) => void
|
||||
filterFn?: (item: any) => boolean
|
||||
getItemClass?: (item: any) => string | string[] | Record<string, boolean>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['clicked', 'delete', 'loading-complete'])
|
||||
|
||||
const items = ref<any[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
const selectedItems = ref<string[]>([])
|
||||
|
||||
defineExpose({
|
||||
items,
|
||||
selectedItems,
|
||||
})
|
||||
|
||||
const fetchItems = async () => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
console.log(`Fetching items from: ${props.fetchUrl}`)
|
||||
try {
|
||||
const resp = await fetch(props.fetchUrl)
|
||||
console.log(`Fetch response status: ${resp.status}`)
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
const data = await resp.json()
|
||||
//console log all data
|
||||
console.log('Fetched data:', data)
|
||||
let itemList = data[props.itemKey || 'items'] || []
|
||||
if (props.filterFn) itemList = itemList.filter(props.filterFn)
|
||||
const initiallySelected: string[] = []
|
||||
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 {
|
||||
console.error('Error fetching image for item', item.id)
|
||||
item[`${field.replace('_id', '_url')}`] = null
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (item.image_id) {
|
||||
try {
|
||||
item.image_url = await getCachedImageUrl(item.image_id)
|
||||
} catch {
|
||||
item.image_url = null
|
||||
}
|
||||
}
|
||||
//for each item see it there is an 'assigned' field that is true. if so check the item's selectable checkbox
|
||||
if (props.selectable && item.assigned === true) {
|
||||
initiallySelected.push(item.id)
|
||||
}
|
||||
}),
|
||||
)
|
||||
items.value = itemList
|
||||
if (props.selectable) {
|
||||
selectedItems.value = initiallySelected
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to fetch items'
|
||||
items.value = []
|
||||
if (props.selectable) selectedItems.value = []
|
||||
} finally {
|
||||
emit('loading-complete', items.value.length)
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchItems)
|
||||
watch(() => props.fetchUrl, fetchItems)
|
||||
|
||||
const handleClicked = (item: any) => {
|
||||
emit('clicked', item)
|
||||
props.onClicked?.(item)
|
||||
}
|
||||
const handleDelete = (item: any) => {
|
||||
emit('delete', item)
|
||||
props.onDelete?.(item)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="listbox">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
<div v-else-if="error" class="error">{{ error }}</div>
|
||||
<div v-else-if="items.length === 0" class="empty">No items found.</div>
|
||||
<div v-else>
|
||||
<div v-for="(item, idx) in items" :key="item.id" class="list-row">
|
||||
<div :class="['list-item', props.getItemClass?.(item)]" @click.stop="handleClicked(item)">
|
||||
<slot name="item" :item="item">
|
||||
<!-- Default rendering if no slot is provided -->
|
||||
<img v-if="item.image_url" :src="item.image_url" />
|
||||
<span class="list-name">List Item</span>
|
||||
<span class="list-value">1</span>
|
||||
</slot>
|
||||
<div v-if="props.selectable || props.deletable" class="interact">
|
||||
<input
|
||||
v-if="props.selectable"
|
||||
type="checkbox"
|
||||
class="list-checkbox"
|
||||
v-model="selectedItems"
|
||||
:value="item.id"
|
||||
@click.stop
|
||||
/>
|
||||
<button
|
||||
v-if="props.deletable"
|
||||
class="delete-btn"
|
||||
@click.stop="handleDelete(item)"
|
||||
aria-label="Delete item"
|
||||
type="button"
|
||||
>
|
||||
<!-- SVG icon here -->
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
||||
<circle cx="10" cy="10" r="9" fill="#fff" stroke="#ef4444" stroke-width="2" />
|
||||
<path
|
||||
d="M7 7l6 6M13 7l-6 6"
|
||||
stroke="#ef4444"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="idx < items.length - 1" class="list-separator"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.listbox {
|
||||
flex: 0 1 auto;
|
||||
max-width: 480px;
|
||||
width: 100%;
|
||||
max-height: calc(100vh - 4.5rem);
|
||||
overflow-y: auto;
|
||||
margin: 0.2rem 0 0 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.7rem;
|
||||
background: var(--list-bg);
|
||||
padding: 0.2rem 0.2rem 0.2rem;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: 2px outset var(--list-item-border-reward);
|
||||
border-radius: 8px;
|
||||
padding: 0.2rem 1rem;
|
||||
background: var(--list-item-bg);
|
||||
font-size: 1.05rem;
|
||||
font-weight: 500;
|
||||
transition: border 0.18s;
|
||||
margin-bottom: 0.2rem;
|
||||
margin-left: 0.2rem;
|
||||
margin-right: 0.2rem;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.03);
|
||||
box-sizing: border-box;
|
||||
justify-content: space-between;
|
||||
cursor: pointer;
|
||||
}
|
||||
.list-item .interact {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Image styles */
|
||||
:deep(.list-item img) {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
margin-right: 0.7rem;
|
||||
background: var(--list-image-bg);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Name/label styles */
|
||||
.list-name {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Points/cost/requested text */
|
||||
.list-value {
|
||||
min-width: 60px;
|
||||
text-align: right;
|
||||
font-weight: 600;
|
||||
}
|
||||
.delete-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
margin-left: 0.7rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
transition:
|
||||
background 0.15s,
|
||||
box-shadow 0.15s;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
opacity: 0.92;
|
||||
}
|
||||
.delete-btn:hover {
|
||||
background: var(--delete-btn-hover-bg);
|
||||
box-shadow: 0 0 0 2px var(--delete-btn-hover-shadow);
|
||||
opacity: 1;
|
||||
}
|
||||
.delete-btn svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Checkbox */
|
||||
.list-checkbox {
|
||||
margin-left: 1rem;
|
||||
width: 1.2em;
|
||||
height: 1.2em;
|
||||
accent-color: var(--checkbox-accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Loading, error, empty states */
|
||||
.loading,
|
||||
.empty {
|
||||
margin: 1.2rem 0;
|
||||
color: var(--list-loading-color);
|
||||
font-size: 1.15rem;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.error {
|
||||
color: var(--error);
|
||||
margin-top: 0.7rem;
|
||||
text-align: center;
|
||||
background: var(--error-bg);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
}
|
||||
/* Separator (if needed) */
|
||||
.list-separator {
|
||||
height: 0px;
|
||||
background: #0000;
|
||||
margin: 0rem 0.2rem;
|
||||
border-radius: 0px;
|
||||
}
|
||||
</style>
|
||||
190
frontend/vue-app/src/components/shared/LoginButton.vue
Normal file
190
frontend/vue-app/src/components/shared/LoginButton.vue
Normal file
@@ -0,0 +1,190 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, nextTick, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { eventBus } from '@/common/eventBus'
|
||||
import { authenticateParent, isParentAuthenticated, logoutParent } from '../../stores/auth'
|
||||
import '@/assets/modal.css'
|
||||
import '@/assets/actions-shared.css'
|
||||
|
||||
const router = useRouter()
|
||||
const show = ref(false)
|
||||
const pin = ref('')
|
||||
const error = ref('')
|
||||
const pinInput = ref<HTMLInputElement | null>(null)
|
||||
const dropdownOpen = ref(false)
|
||||
|
||||
const open = async () => {
|
||||
pin.value = ''
|
||||
error.value = ''
|
||||
show.value = true
|
||||
await nextTick()
|
||||
pinInput.value?.focus()
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
show.value = false
|
||||
error.value = ''
|
||||
}
|
||||
|
||||
const submit = () => {
|
||||
const isDigits = /^\d{4,6}$/.test(pin.value)
|
||||
if (!isDigits) {
|
||||
error.value = 'Enter 4–6 digits'
|
||||
return
|
||||
}
|
||||
|
||||
if (pin.value !== '1179') {
|
||||
error.value = 'Incorrect PIN'
|
||||
return
|
||||
}
|
||||
|
||||
// Authenticate parent and navigate
|
||||
authenticateParent()
|
||||
close()
|
||||
router.push('/parent')
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
logoutParent()
|
||||
router.push('/child')
|
||||
}
|
||||
|
||||
function toggleDropdown() {
|
||||
dropdownOpen.value = !dropdownOpen.value
|
||||
}
|
||||
|
||||
async function signOut() {
|
||||
try {
|
||||
await fetch('/api/logout', { method: 'POST' })
|
||||
logoutParent()
|
||||
router.push('/auth')
|
||||
} catch {
|
||||
// Optionally show error
|
||||
}
|
||||
dropdownOpen.value = false
|
||||
}
|
||||
|
||||
function goToProfile() {
|
||||
router.push('/parent/profile')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
eventBus.on('open-login', open)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
eventBus.off('open-login', open)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div style="position: relative">
|
||||
<button v-if="!isParentAuthenticated" @click="open" aria-label="Parent login" class="login-btn">
|
||||
Parent
|
||||
</button>
|
||||
<div v-else style="display: inline-block; position: relative">
|
||||
<button
|
||||
@click="toggleDropdown"
|
||||
aria-label="Parent menu"
|
||||
class="login-btn"
|
||||
style="min-width: 80px"
|
||||
>
|
||||
Parent ▼
|
||||
</button>
|
||||
<div
|
||||
v-if="dropdownOpen"
|
||||
class="dropdown-menu"
|
||||
style="
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 100%;
|
||||
background: #fff;
|
||||
border: 1px solid #e6e6e6;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
min-width: 120px;
|
||||
z-index: 10;
|
||||
"
|
||||
>
|
||||
<button class="menu-item" @click="goToProfile" style="width: 100%; text-align: left">
|
||||
Profile
|
||||
</button>
|
||||
<button class="menu-item" @click="handleLogout" style="width: 100%; text-align: left">
|
||||
Log out
|
||||
</button>
|
||||
<button class="menu-item danger" @click="signOut" style="width: 100%; text-align: left">
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="show" class="modal-backdrop" @click.self="close">
|
||||
<div class="modal">
|
||||
<h3>Enter parent PIN</h3>
|
||||
<form @submit.prevent="submit">
|
||||
<input
|
||||
ref="pinInput"
|
||||
v-model="pin"
|
||||
inputmode="numeric"
|
||||
pattern="\d*"
|
||||
maxlength="6"
|
||||
placeholder="4–6 digits"
|
||||
class="pin-input"
|
||||
/>
|
||||
<div class="actions">
|
||||
<button type="button" class="btn btn-secondary" @click="close">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">OK</button>
|
||||
</div>
|
||||
</form>
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* modal */
|
||||
|
||||
.pin-input {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.6rem;
|
||||
font-size: 1rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e6e6e6;
|
||||
margin-bottom: 0.6rem;
|
||||
box-sizing: border-box;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
.menu-item {
|
||||
padding: 1rem 0.9rem;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
color: var(--menu-item-color, #333);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.menu-item + .menu-item {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.menu-item:hover {
|
||||
background: var(--menu-item-hover-bg, rgba(0, 0, 0, 0.04));
|
||||
}
|
||||
.menu-item.danger {
|
||||
color: var(--menu-item-danger, #ff4d4f);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.menu-item {
|
||||
padding: 0.85rem 0.7rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.menu-item + .menu-item {
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
28
frontend/vue-app/src/components/shared/MessageBlock.vue
Normal file
28
frontend/vue-app/src/components/shared/MessageBlock.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<div class="message-block">
|
||||
<div>{{ message }}</div>
|
||||
<div class="sub-message">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import '@/assets/global.css'
|
||||
defineProps<{ message: string }>()
|
||||
</script>
|
||||
<style scoped>
|
||||
.message-block {
|
||||
margin: 2rem 0;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
color: var(--message-block-color);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.sub-message {
|
||||
margin-top: 0.3rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 400;
|
||||
color: var(--sub-message-color);
|
||||
}
|
||||
</style>
|
||||
32
frontend/vue-app/src/components/shared/ModalDialog.vue
Normal file
32
frontend/vue-app/src/components/shared/ModalDialog.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<template>
|
||||
<div class="modal-backdrop">
|
||||
<div class="modal-dialog">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
// No script content needed unless you want to add props or logic
|
||||
</script>
|
||||
<style scoped>
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
.modal-dialog {
|
||||
background: #fff;
|
||||
padding: 2rem;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
|
||||
max-width: 340px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
312
frontend/vue-app/src/components/shared/ScrollingList.vue
Normal file
312
frontend/vue-app/src/components/shared/ScrollingList.vue
Normal file
@@ -0,0 +1,312 @@
|
||||
<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>
|
||||
}>()
|
||||
|
||||
// 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
|
||||
}>()
|
||||
|
||||
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 readyItemId = 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
|
||||
|
||||
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 || lastCenteredItemId.value !== item.id) {
|
||||
// Center the item, but don't trigger
|
||||
await centerItem(item.id)
|
||||
lastCenteredItemId.value = item.id
|
||||
readyItemId.value = item.id
|
||||
return
|
||||
}
|
||||
emit(
|
||||
'trigger-item',
|
||||
items.value.find((i) => i.id === item.id),
|
||||
)
|
||||
readyItemId.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)"
|
||||
>
|
||||
<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));
|
||||
}
|
||||
|
||||
@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: #888;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<div v-if="message" class="success-message" aria-live="polite">{{ message }}</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
defineProps<{ message: string }>()
|
||||
</script>
|
||||
Reference in New Issue
Block a user