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.
281 lines
7.0 KiB
Vue
281 lines
7.0 KiB
Vue
<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>
|
|
testItems?: any[] // <-- for test injection
|
|
}>()
|
|
|
|
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[]>([])
|
|
|
|
function refresh() {
|
|
fetchItems()
|
|
}
|
|
|
|
defineExpose({
|
|
items,
|
|
selectedItems,
|
|
refresh,
|
|
})
|
|
|
|
const fetchItems = async () => {
|
|
loading.value = true
|
|
error.value = null
|
|
try {
|
|
// Use testItems if provided
|
|
let itemList = props.testItems ?? []
|
|
if (!itemList.length) {
|
|
const resp = await fetch(props.fetchUrl)
|
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
|
const data = await resp.json()
|
|
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
|
|
}
|
|
}
|
|
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) => {
|
|
if (props.selectable) {
|
|
const idx = selectedItems.value.indexOf(item.id)
|
|
if (idx === -1) {
|
|
selectedItems.value.push(item.id)
|
|
} else {
|
|
selectedItems.value.splice(idx, 1)
|
|
}
|
|
}
|
|
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 && item.user_id"
|
|
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>
|