Moved things around
Some checks failed
Gitea Actions Demo / build-and-push (push) Failing after 6s

This commit is contained in:
2026-01-21 17:18:58 -05:00
parent a47df7171c
commit a0a059472b
160 changed files with 100 additions and 17 deletions

View 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>

View 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>

View 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>

View File

@@ -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>

View 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>

View 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 46 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="46 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>

View 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>

View 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>

View 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>

View File

@@ -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>