Refactored frontend directory
Some checks failed
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 2m46s
Some checks failed
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 2m46s
This commit is contained in:
530
frontend/src/components/shared/ChildrenListView.vue
Normal file
530
frontend/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')">Switch</button> to parent
|
||||
mode 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>
|
||||
94
frontend/src/components/shared/DateInputField.vue
Normal file
94
frontend/src/components/shared/DateInputField.vue
Normal file
@@ -0,0 +1,94 @@
|
||||
<template>
|
||||
<div class="date-input-wrapper">
|
||||
<button type="button" class="date-display-btn" @click="openPicker">
|
||||
{{ displayLabel }}
|
||||
</button>
|
||||
<input
|
||||
ref="pickerRef"
|
||||
class="date-input-hidden"
|
||||
type="date"
|
||||
:value="modelValue"
|
||||
:min="min"
|
||||
@change="onChanged"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string
|
||||
min?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string): void
|
||||
}>()
|
||||
|
||||
const pickerRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const displayLabel = computed(() => {
|
||||
if (!props.modelValue) return 'Select date'
|
||||
const [y, m, d] = props.modelValue.split('-').map(Number)
|
||||
const date = new Date(y, m - 1, d)
|
||||
if (isNaN(date.getTime())) return props.modelValue
|
||||
return date.toLocaleDateString('en-US', {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})
|
||||
})
|
||||
|
||||
function openPicker() {
|
||||
if (pickerRef.value) {
|
||||
if (typeof pickerRef.value.showPicker === 'function') {
|
||||
pickerRef.value.showPicker()
|
||||
} else {
|
||||
pickerRef.value.click()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onChanged(event: Event) {
|
||||
emit('update:modelValue', (event.target as HTMLInputElement).value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.date-input-wrapper {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.date-display-btn {
|
||||
padding: 0.4rem 0.6rem;
|
||||
border: 1.5px solid var(--kebab-menu-border, #bcc1c9);
|
||||
border-radius: 6px;
|
||||
background: var(--modal-bg, #fff);
|
||||
color: var(--secondary, #7257b3);
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.date-display-btn:hover,
|
||||
.date-display-btn:focus {
|
||||
border-color: var(--btn-primary, #667eea);
|
||||
}
|
||||
|
||||
.date-input-hidden {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
60
frontend/src/components/shared/DeleteModal.vue
Normal file
60
frontend/src/components/shared/DeleteModal.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<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'
|
||||
import '@/assets/styles.css'
|
||||
|
||||
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>
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 3rem;
|
||||
justify-content: center;
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.actions .btn {
|
||||
padding: 1rem 2.2rem;
|
||||
font-weight: 700;
|
||||
font-size: 1.25rem;
|
||||
min-width: 120px;
|
||||
}
|
||||
</style>
|
||||
302
frontend/src/components/shared/EntityEditForm.vue
Normal file
302
frontend/src/components/shared/EntityEditForm.vue
Normal file
@@ -0,0 +1,302 @@
|
||||
<template>
|
||||
<h2>{{ title ?? (isEdit ? `Edit ${entityLabel}` : `Create ${entityLabel}`) }}</h2>
|
||||
<div v-if="loading" class="loading-message">Loading {{ entityLabel.toLowerCase() }}...</div>
|
||||
<form v-else @submit.prevent="submit" class="entity-form" ref="formRef">
|
||||
<template v-for="field in fields" :key="field.name">
|
||||
<div class="group">
|
||||
<ToggleField
|
||||
v-if="field.type === 'toggle'"
|
||||
:label="field.label"
|
||||
:modelValue="formData[field.name]"
|
||||
@update:modelValue="(val: boolean) => (formData[field.name] = val)"
|
||||
:disabled="field.disabled"
|
||||
:description="field.description"
|
||||
:error="props.fieldErrors?.[field.name]"
|
||||
/>
|
||||
<label v-else :for="field.name">
|
||||
{{ field.label }}
|
||||
<!-- Custom field slot -->
|
||||
<slot
|
||||
:name="`custom-field-${field.name}`"
|
||||
:modelValue="formData[field.name]"
|
||||
:update="(val: unknown) => (formData[field.name] = val)"
|
||||
>
|
||||
<!-- Default rendering if no slot provided -->
|
||||
<input
|
||||
v-if="field.type === 'text'"
|
||||
:id="field.name"
|
||||
v-model="formData[field.name]"
|
||||
type="text"
|
||||
:required="field.required"
|
||||
:maxlength="field.maxlength"
|
||||
/>
|
||||
<input
|
||||
v-else-if="field.type === 'number'"
|
||||
:id="field.name"
|
||||
v-model="formData[field.name]"
|
||||
type="number"
|
||||
:required="field.required"
|
||||
:min="field.min"
|
||||
:max="field.max"
|
||||
inputmode="numeric"
|
||||
pattern="\\d{1,3}"
|
||||
@input="
|
||||
(e) => {
|
||||
if (field.maxlength && e.target.value.length > field.maxlength) {
|
||||
e.target.value = e.target.value.slice(0, field.maxlength)
|
||||
formData[field.name] = e.target.value
|
||||
}
|
||||
}
|
||||
"
|
||||
/>
|
||||
<ImagePicker
|
||||
v-else-if="field.type === 'image'"
|
||||
:id="field.name"
|
||||
v-model="formData[field.name]"
|
||||
:image-type="field.imageType || 1"
|
||||
@add-image="onAddImage"
|
||||
/>
|
||||
</slot>
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
<div class="actions">
|
||||
<button type="button" class="btn btn-secondary" @click="onCancel" :disabled="loading">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
:disabled="loading || !isValid || (props.requireDirty && !isDirty)"
|
||||
>
|
||||
{{ isEdit ? 'Save' : 'Create' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, nextTick, watch, computed } from 'vue'
|
||||
import ImagePicker from '@/components/utils/ImagePicker.vue'
|
||||
import ToggleField from './ToggleField.vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import '@/assets/styles.css'
|
||||
|
||||
type Field = {
|
||||
name: string
|
||||
label: string
|
||||
type: 'text' | 'number' | 'image' | 'custom' | 'toggle'
|
||||
required?: boolean
|
||||
maxlength?: number
|
||||
min?: number
|
||||
max?: number
|
||||
imageType?: number
|
||||
description?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
entityLabel: string
|
||||
fields: Field[]
|
||||
initialData?: Record<string, any>
|
||||
isEdit?: boolean
|
||||
loading?: boolean
|
||||
error?: string | null
|
||||
title?: string
|
||||
requireDirty?: boolean
|
||||
fieldErrors?: Record<string, string>
|
||||
}>(),
|
||||
{
|
||||
requireDirty: true,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits(['submit', 'cancel', 'add-image'])
|
||||
|
||||
const router = useRouter()
|
||||
const formData = ref<Record<string, any>>({ ...props.initialData })
|
||||
const baselineData = ref<Record<string, any>>({ ...props.initialData })
|
||||
const formRef = ref<HTMLFormElement | null>(null)
|
||||
|
||||
async function focusFirstInput() {
|
||||
await nextTick()
|
||||
const firstInput = formRef.value?.querySelector<HTMLElement>('input, select, textarea')
|
||||
firstInput?.focus()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
isDirty.value = false
|
||||
if (!props.loading) {
|
||||
focusFirstInput()
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.loading,
|
||||
(newVal, oldVal) => {
|
||||
if (!newVal && oldVal === true) {
|
||||
focusFirstInput()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
function onAddImage({ id, file }: { id: string; file: File }) {
|
||||
emit('add-image', { id, file })
|
||||
}
|
||||
|
||||
function onCancel() {
|
||||
emit('cancel')
|
||||
}
|
||||
|
||||
function submit() {
|
||||
emit('submit', { ...formData.value })
|
||||
// After submit, reset isDirty so Save button is disabled until next change
|
||||
isDirty.value = false
|
||||
}
|
||||
|
||||
// Editable field names (exclude custom fields that are not editable)
|
||||
const editableFieldNames = props.fields
|
||||
.filter((f) => f.type !== 'custom' || f.name === 'type')
|
||||
.map((f) => f.name)
|
||||
|
||||
const isDirty = ref(false)
|
||||
|
||||
function getFieldByName(name: string): Field | undefined {
|
||||
return props.fields.find((field) => field.name === name)
|
||||
}
|
||||
|
||||
function valuesEqualForDirtyCheck(
|
||||
fieldName: string,
|
||||
currentValue: unknown,
|
||||
initialValue: unknown,
|
||||
): boolean {
|
||||
const field = getFieldByName(fieldName)
|
||||
|
||||
if (field?.type === 'number') {
|
||||
const currentEmpty = currentValue === '' || currentValue === null || currentValue === undefined
|
||||
const initialEmpty = initialValue === '' || initialValue === null || initialValue === undefined
|
||||
if (currentEmpty && initialEmpty) return true
|
||||
if (currentEmpty !== initialEmpty) return false
|
||||
return Number(currentValue) === Number(initialValue)
|
||||
}
|
||||
|
||||
return JSON.stringify(currentValue) === JSON.stringify(initialValue)
|
||||
}
|
||||
|
||||
function checkDirty() {
|
||||
isDirty.value = editableFieldNames.some((key) => {
|
||||
return !valuesEqualForDirtyCheck(key, formData.value[key], baselineData.value[key])
|
||||
})
|
||||
}
|
||||
|
||||
// Validation logic
|
||||
const isValid = computed(() => {
|
||||
return props.fields.every((field) => {
|
||||
if (!field.required) return true
|
||||
const value = formData.value[field.name]
|
||||
|
||||
if (field.type === 'text') {
|
||||
return typeof value === 'string' && value.trim().length > 0
|
||||
}
|
||||
|
||||
if (field.type === 'number') {
|
||||
if (value === '' || value === null || value === undefined) return false
|
||||
const numValue = Number(value)
|
||||
if (isNaN(numValue)) return false
|
||||
if (field.min !== undefined && numValue < field.min) return false
|
||||
if (field.max !== undefined && numValue > field.max) return false
|
||||
return true
|
||||
}
|
||||
|
||||
// For other types, just check it's not null/undefined
|
||||
return value != null
|
||||
})
|
||||
})
|
||||
|
||||
watch(
|
||||
() => ({ ...formData.value }),
|
||||
() => {
|
||||
checkDirty()
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.initialData,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
formData.value = { ...newVal }
|
||||
baselineData.value = { ...newVal }
|
||||
isDirty.value = false
|
||||
}
|
||||
},
|
||||
{ immediate: true, deep: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
h2 {
|
||||
text-align: center;
|
||||
margin-bottom: 1.5rem;
|
||||
color: var(--form-heading);
|
||||
}
|
||||
.entity-edit-view {
|
||||
max-width: 420px;
|
||||
margin: 0 auto;
|
||||
background: var(--edit-view-bg, #fff);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
|
||||
padding: 2.2rem 2.2rem 1.5rem 2.2rem;
|
||||
}
|
||||
.entity-edit-view h2 {
|
||||
text-align: center;
|
||||
margin-bottom: 1.5rem;
|
||||
color: var(--form-heading);
|
||||
}
|
||||
.entity-form .group {
|
||||
margin-bottom: 1.2rem;
|
||||
}
|
||||
.entity-form label {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
color: var(--form-label, #444);
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.entity-form input[type='text'],
|
||||
.entity-form input[type='number'] {
|
||||
width: 100%;
|
||||
padding: 0.6rem;
|
||||
border-radius: 7px;
|
||||
border: 1px solid var(--form-input-border, #e6e6e6);
|
||||
font-size: 1rem;
|
||||
background: var(--form-input-bg, #fff);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 3rem;
|
||||
justify-content: center;
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.actions .btn {
|
||||
padding: 1rem 2.2rem;
|
||||
font-weight: 700;
|
||||
font-size: 1.25rem;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--error-color, #e53e3e);
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.loading-message {
|
||||
color: var(--loading-color, #888);
|
||||
margin-bottom: 1.2rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
</style>
|
||||
6
frontend/src/components/shared/ErrorMessage.vue
Normal file
6
frontend/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>
|
||||
44
frontend/src/components/shared/FloatingActionButton.vue
Normal file
44
frontend/src/components/shared/FloatingActionButton.vue
Normal file
@@ -0,0 +1,44 @@
|
||||
<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">
|
||||
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>
|
||||
280
frontend/src/components/shared/ItemList.vue
Normal file
280
frontend/src/components/shared/ItemList.vue
Normal file
@@ -0,0 +1,280 @@
|
||||
<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>
|
||||
641
frontend/src/components/shared/LoginButton.vue
Normal file
641
frontend/src/components/shared/LoginButton.vue
Normal file
@@ -0,0 +1,641 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, nextTick, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { eventBus } from '@/common/eventBus'
|
||||
import {
|
||||
authenticateParent,
|
||||
isParentAuthenticated,
|
||||
isParentPersistent,
|
||||
logoutParent,
|
||||
logoutUser,
|
||||
consumePendingReturnUrl,
|
||||
hasPendingReturnUrl,
|
||||
clearPendingReturnUrl,
|
||||
} from '../../stores/auth'
|
||||
import { getCachedImageBlob } from '@/common/imageCache'
|
||||
import '@/assets/styles.css'
|
||||
import ModalDialog from './ModalDialog.vue'
|
||||
import {
|
||||
subscribeToPushWithResult,
|
||||
unsubscribeFromPush,
|
||||
isPushOptedOut,
|
||||
} from '@/services/pushSubscription'
|
||||
|
||||
const router = useRouter()
|
||||
const show = ref(false)
|
||||
const pin = ref('')
|
||||
const error = ref('')
|
||||
const stayInParentMode = ref(false)
|
||||
const pinInput = ref<HTMLInputElement | null>(null)
|
||||
const dropdownOpen = ref(false)
|
||||
const dropdownRef = ref<HTMLElement | null>(null)
|
||||
const avatarButtonRef = ref<HTMLButtonElement | null>(null)
|
||||
const focusedMenuIndex = ref(0)
|
||||
|
||||
// User profile data
|
||||
const userImageId = ref<string | null>(null)
|
||||
const userFirstName = ref<string>('')
|
||||
const userEmail = ref<string>('')
|
||||
const profileLoading = ref(true)
|
||||
const avatarImageUrl = ref<string | null>(null)
|
||||
const dropdownAvatarImageUrl = ref<string | null>(null)
|
||||
|
||||
// Compute avatar initial
|
||||
const avatarInitial = ref<string>('?')
|
||||
|
||||
// Fetch user profile
|
||||
async function fetchUserProfile() {
|
||||
try {
|
||||
const res = await fetch('/api/user/profile', { credentials: 'include' })
|
||||
if (!res.ok) {
|
||||
console.error('Failed to fetch user profile')
|
||||
profileLoading.value = false
|
||||
return
|
||||
}
|
||||
const data = await res.json()
|
||||
userImageId.value = data.image_id || null
|
||||
userFirstName.value = data.first_name || ''
|
||||
userEmail.value = data.email || ''
|
||||
|
||||
// Update avatar initial
|
||||
avatarInitial.value = userFirstName.value ? userFirstName.value.charAt(0).toUpperCase() : '?'
|
||||
|
||||
profileLoading.value = false
|
||||
|
||||
// Load cached image if available
|
||||
if (userImageId.value) {
|
||||
await loadAvatarImages(userImageId.value)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error fetching user profile:', e)
|
||||
profileLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAvatarImages(imageId: string) {
|
||||
try {
|
||||
const blob = await getCachedImageBlob(imageId)
|
||||
// Clean up previous URLs
|
||||
if (avatarImageUrl.value) URL.revokeObjectURL(avatarImageUrl.value)
|
||||
if (dropdownAvatarImageUrl.value) URL.revokeObjectURL(dropdownAvatarImageUrl.value)
|
||||
avatarImageUrl.value = URL.createObjectURL(blob)
|
||||
dropdownAvatarImageUrl.value = URL.createObjectURL(blob)
|
||||
} catch (e) {
|
||||
avatarImageUrl.value = null
|
||||
dropdownAvatarImageUrl.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const open = async () => {
|
||||
// Check if user has a pin
|
||||
try {
|
||||
const res = await fetch('/api/user/has-pin', { credentials: 'include' })
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data.error || 'Error checking PIN')
|
||||
if (!data.has_pin) {
|
||||
console.log('No PIN set, redirecting to setup')
|
||||
// Route to PIN setup view
|
||||
router.push('/parent/pin-setup')
|
||||
return
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = 'Network error'
|
||||
return
|
||||
}
|
||||
pin.value = ''
|
||||
error.value = ''
|
||||
show.value = true
|
||||
await nextTick()
|
||||
pinInput.value?.focus()
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
show.value = false
|
||||
error.value = ''
|
||||
stayInParentMode.value = false
|
||||
clearPendingReturnUrl()
|
||||
}
|
||||
|
||||
const submit = async () => {
|
||||
const isDigits = /^\d{4,6}$/.test(pin.value)
|
||||
if (!isDigits) {
|
||||
error.value = 'Enter 4–6 digits'
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/user/check-pin', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ pin: pin.value }),
|
||||
credentials: 'include',
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) {
|
||||
error.value = data.error || 'Error validating PIN'
|
||||
return
|
||||
}
|
||||
if (!data.valid) {
|
||||
error.value = 'Incorrect PIN'
|
||||
pin.value = ''
|
||||
await nextTick()
|
||||
pinInput.value?.focus()
|
||||
return
|
||||
}
|
||||
// Authenticate parent and navigate
|
||||
authenticateParent(stayInParentMode.value)
|
||||
if (!isPushOptedOut()) {
|
||||
subscribeToPushWithResult() // fire-and-forget — browser gesture is satisfied by the PIN button click
|
||||
}
|
||||
const returnUrl = consumePendingReturnUrl()
|
||||
close()
|
||||
router.push(returnUrl || '/parent')
|
||||
} catch (e) {
|
||||
error.value = 'Network error'
|
||||
}
|
||||
}
|
||||
|
||||
function handlePinInput(event: Event) {
|
||||
const target = event.target as HTMLInputElement
|
||||
pin.value = target.value.replace(/\D/g, '').slice(0, 6)
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
unsubscribeFromPush()
|
||||
logoutParent()
|
||||
router.push('/child')
|
||||
}
|
||||
|
||||
function toggleDropdown() {
|
||||
dropdownOpen.value = !dropdownOpen.value
|
||||
if (dropdownOpen.value) {
|
||||
focusedMenuIndex.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
function closeDropdown() {
|
||||
dropdownOpen.value = false
|
||||
focusedMenuIndex.value = 0
|
||||
avatarButtonRef.value?.focus()
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (!dropdownOpen.value) {
|
||||
// Handle avatar button keyboard
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
if (isParentAuthenticated.value) {
|
||||
toggleDropdown()
|
||||
} else {
|
||||
open()
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Handle dropdown keyboard navigation
|
||||
const menuItems = 3 // Profile, Child Mode, Sign Out
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
event.preventDefault()
|
||||
focusedMenuIndex.value = (focusedMenuIndex.value + 1) % menuItems
|
||||
break
|
||||
case 'ArrowUp':
|
||||
event.preventDefault()
|
||||
focusedMenuIndex.value = (focusedMenuIndex.value - 1 + menuItems) % menuItems
|
||||
break
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
event.preventDefault()
|
||||
executeMenuItem(focusedMenuIndex.value)
|
||||
break
|
||||
case 'Escape':
|
||||
event.preventDefault()
|
||||
closeDropdown()
|
||||
break
|
||||
case 'Tab':
|
||||
closeDropdown()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
function executeMenuItem(index: number) {
|
||||
switch (index) {
|
||||
case 0:
|
||||
goToProfile()
|
||||
break
|
||||
case 1:
|
||||
handleLogout()
|
||||
closeDropdown()
|
||||
break
|
||||
case 2:
|
||||
signOut()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
async function signOut() {
|
||||
try {
|
||||
await fetch('/api/auth/logout', { method: 'POST' })
|
||||
logoutParent()
|
||||
logoutUser()
|
||||
router.push('/')
|
||||
} catch {
|
||||
// Optionally show error
|
||||
}
|
||||
closeDropdown()
|
||||
}
|
||||
|
||||
function goToProfile() {
|
||||
router.push('/parent/profile')
|
||||
closeDropdown()
|
||||
}
|
||||
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (
|
||||
dropdownOpen.value &&
|
||||
dropdownRef.value &&
|
||||
!dropdownRef.value.contains(event.target as Node)
|
||||
) {
|
||||
closeDropdown()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
eventBus.on('open-login', open)
|
||||
eventBus.on('profile_updated', fetchUserProfile)
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
fetchUserProfile()
|
||||
if (!isParentAuthenticated.value && hasPendingReturnUrl()) {
|
||||
open()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
eventBus.off('open-login', open)
|
||||
eventBus.off('profile_updated', fetchUserProfile)
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
|
||||
// Revoke object URL to free memory
|
||||
if (avatarImageUrl.value) URL.revokeObjectURL(avatarImageUrl.value)
|
||||
if (dropdownAvatarImageUrl.value) URL.revokeObjectURL(dropdownAvatarImageUrl.value)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div style="position: relative">
|
||||
<button
|
||||
ref="avatarButtonRef"
|
||||
@click="isParentAuthenticated ? toggleDropdown() : open()"
|
||||
@keydown="handleKeyDown"
|
||||
:aria-label="isParentAuthenticated ? 'Parent menu' : 'Parent login'"
|
||||
aria-haspopup="menu"
|
||||
:aria-expanded="isParentAuthenticated && dropdownOpen ? 'true' : 'false'"
|
||||
class="avatar-btn"
|
||||
>
|
||||
<img
|
||||
v-if="avatarImageUrl && !profileLoading"
|
||||
:src="avatarImageUrl"
|
||||
:alt="userFirstName || 'User avatar'"
|
||||
class="avatar-image"
|
||||
/>
|
||||
<span v-else class="avatar-text">{{ profileLoading ? '...' : avatarInitial }}</span>
|
||||
</button>
|
||||
<span
|
||||
v-if="isParentAuthenticated && isParentPersistent"
|
||||
class="persistent-badge"
|
||||
aria-label="Persistent parent mode active"
|
||||
>🔒</span
|
||||
>
|
||||
|
||||
<Transition name="slide-fade">
|
||||
<div
|
||||
v-if="isParentAuthenticated && dropdownOpen"
|
||||
ref="dropdownRef"
|
||||
role="menu"
|
||||
class="dropdown-menu"
|
||||
>
|
||||
<div class="dropdown-header">
|
||||
<img
|
||||
v-if="avatarImageUrl"
|
||||
:src="avatarImageUrl"
|
||||
:alt="userFirstName || 'User avatar'"
|
||||
class="dropdown-avatar"
|
||||
/>
|
||||
<span v-else class="dropdown-avatar-initial">{{ avatarInitial }}</span>
|
||||
<div class="dropdown-user-info">
|
||||
<div class="dropdown-user-name">{{ userFirstName || 'User' }}</div>
|
||||
<div v-if="userEmail" class="dropdown-user-email">{{ userEmail }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
role="menuitem"
|
||||
:aria-selected="focusedMenuIndex === 0"
|
||||
:class="['menu-item', { focused: focusedMenuIndex === 0 }]"
|
||||
@mouseenter="focusedMenuIndex = 0"
|
||||
@click="goToProfile"
|
||||
>
|
||||
<span class="menu-icon-stub">
|
||||
<img
|
||||
src="/profile.png"
|
||||
alt="Profile"
|
||||
style="width: 20px; height: 20px; object-fit: contain; vertical-align: middle"
|
||||
/>
|
||||
</span>
|
||||
<span>Profile</span>
|
||||
</button>
|
||||
<button
|
||||
role="menuitem"
|
||||
:aria-selected="focusedMenuIndex === 1"
|
||||
:class="['menu-item', { focused: focusedMenuIndex === 1 }]"
|
||||
@mouseenter="focusedMenuIndex = 1"
|
||||
@click="
|
||||
() => {
|
||||
handleLogout()
|
||||
closeDropdown()
|
||||
}
|
||||
"
|
||||
>
|
||||
<span class="menu-icon-stub">
|
||||
<img
|
||||
src="/child-mode.png"
|
||||
alt="Child Mode"
|
||||
style="width: 20px; height: 20px; object-fit: contain; vertical-align: middle"
|
||||
/>
|
||||
</span>
|
||||
<span>Child Mode</span>
|
||||
</button>
|
||||
<button
|
||||
role="menuitem"
|
||||
:aria-selected="focusedMenuIndex === 2"
|
||||
:class="['menu-item', 'danger', { focused: focusedMenuIndex === 2 }]"
|
||||
@mouseenter="focusedMenuIndex = 2"
|
||||
@click="signOut"
|
||||
>
|
||||
<span class="menu-icon-stub">
|
||||
<img
|
||||
src="/sign-out.png"
|
||||
alt="Sign out"
|
||||
style="width: 20px; height: 20px; object-fit: contain; vertical-align: middle"
|
||||
/>
|
||||
</span>
|
||||
<span>Sign out</span>
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<ModalDialog v-if="show" title="Enter parent PIN" @click.self="close" @close="close">
|
||||
<form @submit.prevent="submit">
|
||||
<input
|
||||
ref="pinInput"
|
||||
v-model="pin"
|
||||
@input="handlePinInput"
|
||||
inputmode="numeric"
|
||||
pattern="\d*"
|
||||
maxlength="6"
|
||||
placeholder="4–6 digits"
|
||||
class="pin-input"
|
||||
/>
|
||||
<label class="stay-label">
|
||||
<input type="checkbox" v-model="stayInParentMode" class="stay-checkbox" />
|
||||
Stay in parent mode on this device
|
||||
</label>
|
||||
<div class="actions modal-actions">
|
||||
<button type="button" class="btn btn-secondary" @click="close">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="pin.length < 4">OK</button>
|
||||
</div>
|
||||
</form>
|
||||
<div v-if="error" class="error modal-message">{{ error }}</div>
|
||||
</ModalDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.error {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.avatar-btn {
|
||||
width: 44px;
|
||||
min-width: 44px;
|
||||
max-width: 44px;
|
||||
height: 44px;
|
||||
min-height: 44px;
|
||||
max-height: 44px;
|
||||
margin: 0;
|
||||
background: var(--button-bg, #fff);
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
transition:
|
||||
transform 0.18s,
|
||||
box-shadow 0.18s;
|
||||
}
|
||||
|
||||
.avatar-btn:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.2);
|
||||
}
|
||||
|
||||
.avatar-btn:focus-visible {
|
||||
outline: 3px solid var(--primary, #667eea);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.avatar-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.avatar-text {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--button-text, #667eea);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.avatar-text {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.pin-input {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.6rem;
|
||||
font-size: 1rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e6e6e6;
|
||||
margin-bottom: 0.8rem;
|
||||
box-sizing: border-box;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stay-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--form-label, #444);
|
||||
cursor: pointer;
|
||||
margin-bottom: 1rem;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.stay-checkbox {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: var(--btn-primary, #667eea);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.persistent-badge {
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
left: -2px;
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: calc(100% + 8px);
|
||||
background: var(--form-bg, #fff);
|
||||
border: 1px solid var(--form-input-border, #cbd5e1);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||
min-width: 240px;
|
||||
z-index: 100;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dropdown-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: var(--header-bg, linear-gradient(135deg, #667eea 0%, #764ba2 100%));
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.dropdown-avatar,
|
||||
.dropdown-avatar-initial {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
min-width: 48px;
|
||||
min-height: 48px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.dropdown-avatar-initial {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.dropdown-user-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dropdown-user-name {
|
||||
font-weight: 700;
|
||||
font-size: 1.1rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.dropdown-user-email {
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.9;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 14px 16px;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
color: var(--form-label, #444);
|
||||
font-size: 0.95rem;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.menu-item.focused {
|
||||
background: var(--btn-secondary-hover, #e2e8f0);
|
||||
}
|
||||
|
||||
.menu-item:focus-visible {
|
||||
outline: 2px solid var(--primary, #667eea);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.menu-item.danger {
|
||||
color: var(--btn-danger, #ef4444);
|
||||
}
|
||||
|
||||
.menu-icon-stub {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: var(--form-input-bg, #f8fafc);
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Slide fade animation */
|
||||
.slide-fade-enter-active,
|
||||
.slide-fade-leave-active {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.slide-fade-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
|
||||
.slide-fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.menu-item {
|
||||
padding: 12px 14px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
min-width: 200px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
27
frontend/src/components/shared/MessageBlock.vue
Normal file
27
frontend/src/components/shared/MessageBlock.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<div class="message-block">
|
||||
<div>{{ message }}</div>
|
||||
<div class="sub-message">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
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>
|
||||
112
frontend/src/components/shared/ModalDialog.vue
Normal file
112
frontend/src/components/shared/ModalDialog.vue
Normal file
@@ -0,0 +1,112 @@
|
||||
<template>
|
||||
<div class="modal-backdrop" @click.self="$emit('backdrop-click')">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-heading">
|
||||
<img v-if="imageUrl" :src="imageUrl" alt="Dialog Image" class="modal-image" />
|
||||
<div class="modal-details">
|
||||
<div class="modal-title">{{ title }}</div>
|
||||
<div class="modal-subtitle">{{ subtitle }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
imageUrl?: string | null | undefined
|
||||
title?: string
|
||||
subtitle?: string | null | undefined
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'backdrop-click': []
|
||||
}>()
|
||||
</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: var(--modal-bg, #fff);
|
||||
padding: 2rem;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
|
||||
max-width: 340px;
|
||||
text-align: center;
|
||||
}
|
||||
.modal-image {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
background: var(--info-image-bg);
|
||||
}
|
||||
.modal-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.modal-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin: auto;
|
||||
}
|
||||
.modal-title {
|
||||
font-weight: 600;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.modal-subtitle {
|
||||
color: var(--info-points);
|
||||
font-weight: 500;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* Encapsulated slot content styles */
|
||||
.modal-message {
|
||||
margin-bottom: 1.2rem;
|
||||
font-size: 1rem;
|
||||
color: var(--modal-message-color, #333);
|
||||
}
|
||||
|
||||
:deep(.modal-actions) {
|
||||
display: flex;
|
||||
gap: 3rem;
|
||||
justify-content: center;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
:deep(.modal-actions button) {
|
||||
padding: 1rem 2.2rem;
|
||||
border-radius: 12px;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
font-size: 1.25rem;
|
||||
transition: background 0.18s;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
:deep(.modal-actions) {
|
||||
gap: 1.2rem;
|
||||
}
|
||||
:deep(.modal-actions button) {
|
||||
padding: 0.8rem 1.2rem;
|
||||
font-size: 1.05rem;
|
||||
min-width: 90px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
749
frontend/src/components/shared/ScheduleModal.vue
Normal file
749
frontend/src/components/shared/ScheduleModal.vue
Normal file
@@ -0,0 +1,749 @@
|
||||
<template>
|
||||
<ModalDialog :image-url="task.image_url" :title="'Schedule Chore'" :subtitle="task.name">
|
||||
<!-- Enable/disable toggle row -->
|
||||
<div class="schedule-toggle-row">
|
||||
<span class="toggle-label">{{ scheduleEnabled ? 'Enabled' : 'Paused' }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="toggle-track"
|
||||
:class="{ on: scheduleEnabled }"
|
||||
role="switch"
|
||||
:aria-checked="scheduleEnabled"
|
||||
@click="scheduleEnabled = !scheduleEnabled"
|
||||
>
|
||||
<span class="toggle-thumb" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Form body dims when paused -->
|
||||
<div class="schedule-body" :class="{ disabled: !scheduleEnabled }">
|
||||
<!-- Mode toggle -->
|
||||
<div class="mode-toggle">
|
||||
<button :class="['mode-btn', { active: mode === 'days' }]" @click="mode = 'days'">
|
||||
Specific Days
|
||||
</button>
|
||||
<button :class="['mode-btn', { active: mode === 'interval' }]" @click="mode = 'interval'">
|
||||
Every X Days
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Specific Days form -->
|
||||
<div v-if="mode === 'days'" class="days-form">
|
||||
<!-- Day chips -->
|
||||
<div class="day-chips">
|
||||
<button
|
||||
v-for="(label, idx) in DAY_CHIP_LABELS"
|
||||
:key="idx"
|
||||
type="button"
|
||||
:class="['chip', { active: selectedDays.has(idx) }]"
|
||||
@click="toggleDay(idx)"
|
||||
>
|
||||
{{ label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Default deadline -->
|
||||
<div v-if="selectedDays.size > 0" class="default-deadline-row">
|
||||
<span class="field-label">Deadline</span>
|
||||
<TimePickerPopover
|
||||
v-if="hasDefaultDeadline"
|
||||
:modelValue="defaultTime"
|
||||
@update:modelValue="onDefaultTimeChanged"
|
||||
/>
|
||||
<span v-else class="anytime-label">Anytime</span>
|
||||
<button type="button" class="link-btn" @click="hasDefaultDeadline = !hasDefaultDeadline">
|
||||
{{ hasDefaultDeadline ? 'Clear (Anytime)' : 'Set deadline' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Selected day exception list -->
|
||||
<div v-if="selectedDays.size > 0" class="exception-list">
|
||||
<div v-for="idx in sortedSelectedDays" :key="idx" class="exception-row">
|
||||
<span class="exception-day-name">{{ DAY_LABELS[idx] }}</span>
|
||||
<div class="exception-right">
|
||||
<template v-if="exceptions.has(idx)">
|
||||
<TimePickerPopover
|
||||
:modelValue="exceptions.get(idx)!"
|
||||
@update:modelValue="(val) => exceptions.set(idx, val)"
|
||||
/>
|
||||
<button type="button" class="link-btn reset-btn" @click="exceptions.delete(idx)">
|
||||
Reset to default
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="default-label">{{
|
||||
hasDefaultDeadline ? formatDefaultTime : 'Anytime'
|
||||
}}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="link-btn"
|
||||
@click="exceptions.set(idx, { ...defaultTime })"
|
||||
>
|
||||
Set different time
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Interval form -->
|
||||
<div v-else class="interval-form">
|
||||
<!-- Frequency stepper + anchor date -->
|
||||
<div class="interval-group">
|
||||
<div class="interval-row">
|
||||
<label class="field-label">Every</label>
|
||||
<div class="stepper">
|
||||
<button
|
||||
type="button"
|
||||
class="stepper-btn"
|
||||
@click="decrementInterval"
|
||||
:disabled="intervalDays <= 1"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span class="stepper-value">{{ intervalDays }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="stepper-btn"
|
||||
@click="incrementInterval"
|
||||
:disabled="intervalDays >= 7"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<span class="field-label">{{ intervalDays === 1 ? 'day' : 'days' }}</span>
|
||||
</div>
|
||||
<div class="interval-row">
|
||||
<label class="field-label">Starting on</label>
|
||||
<DateInputField
|
||||
:modelValue="anchorDate"
|
||||
:min="todayISO"
|
||||
@update:modelValue="anchorDate = $event"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Next occurrence preview -->
|
||||
<div v-if="nextOccurrence" class="next-occurrence-row">
|
||||
<span class="next-occurrence-label">Next occurrence: {{ nextOccurrence }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Deadline row -->
|
||||
<div class="interval-time-row">
|
||||
<label class="field-label">Deadline</label>
|
||||
<TimePickerPopover
|
||||
v-if="hasDeadline"
|
||||
:modelValue="intervalTime"
|
||||
@update:modelValue="intervalTime = $event"
|
||||
/>
|
||||
<span v-else class="anytime-label">Anytime</span>
|
||||
<button type="button" class="link-btn" @click="toggleDeadline">
|
||||
{{ hasDeadline ? 'Clear (Anytime)' : 'Set deadline' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="errorMsg" class="error-msg">{{ errorMsg }}</p>
|
||||
</div>
|
||||
<!-- /schedule-body -->
|
||||
|
||||
<!-- Actions outside dim wrapper so they stay interactive -->
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" @click="$emit('cancelled')" :disabled="saving">
|
||||
Cancel
|
||||
</button>
|
||||
<button class="btn-primary" @click="save" :disabled="saving || !isValid || !isDirty">
|
||||
{{ saving ? 'Saving…' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
</ModalDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import ModalDialog from './ModalDialog.vue'
|
||||
import TimePickerPopover from './TimePickerPopover.vue'
|
||||
import DateInputField from './DateInputField.vue'
|
||||
import { setChoreSchedule, deleteChoreSchedule, parseErrorResponse } from '@/common/api'
|
||||
import type { ChildTask, ChoreSchedule, DayConfig } from '@/common/models'
|
||||
|
||||
interface TimeValue {
|
||||
hour: number
|
||||
minute: number
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
task: ChildTask
|
||||
childId: string
|
||||
schedule: ChoreSchedule | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'saved'): void
|
||||
(e: 'cancelled'): void
|
||||
}>()
|
||||
|
||||
const DAY_LABELS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
|
||||
const DAY_CHIP_LABELS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']
|
||||
|
||||
// ── local state ──────────────────────────────────────────────────────────────
|
||||
|
||||
const mode = ref<'days' | 'interval'>(props.schedule?.mode ?? 'days')
|
||||
|
||||
// days mode — three-layer state
|
||||
function initDaysState(schedule: ChoreSchedule | null): {
|
||||
days: Set<number>
|
||||
base: TimeValue
|
||||
exMap: Map<number, TimeValue>
|
||||
} {
|
||||
const days = new Set<number>()
|
||||
const exMap = new Map<number, TimeValue>()
|
||||
let base: TimeValue = { hour: 8, minute: 0 }
|
||||
|
||||
if (schedule?.mode === 'days') {
|
||||
// Load the persisted default time (falls back to 8:00 AM for old records)
|
||||
base = { hour: schedule.default_hour ?? 8, minute: schedule.default_minute ?? 0 }
|
||||
for (const dc of schedule.day_configs) {
|
||||
days.add(dc.day)
|
||||
if (dc.hour !== base.hour || dc.minute !== base.minute) {
|
||||
exMap.set(dc.day, { hour: dc.hour, minute: dc.minute })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { days, base, exMap }
|
||||
}
|
||||
|
||||
const { days: _days, base: _base, exMap: _exMap } = initDaysState(props.schedule)
|
||||
const selectedDays = ref<Set<number>>(_days)
|
||||
const defaultTime = ref<TimeValue>(_base)
|
||||
const exceptions = ref<Map<number, TimeValue>>(_exMap)
|
||||
const hasDefaultDeadline = ref<boolean>(props.schedule?.default_has_deadline ?? true)
|
||||
const scheduleEnabled = ref<boolean>(props.schedule?.enabled ?? true)
|
||||
|
||||
// ── helpers (date) ───────────────────────────────────────────────────────────
|
||||
|
||||
function getTodayISO(): string {
|
||||
const d = new Date()
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
// interval mode
|
||||
const intervalDays = ref<number>(Math.max(1, props.schedule?.interval_days ?? 1))
|
||||
const anchorDate = ref<string>(props.schedule?.anchor_date || getTodayISO())
|
||||
const hasDeadline = ref<boolean>(props.schedule?.interval_has_deadline ?? true)
|
||||
const intervalTime = ref<TimeValue>({
|
||||
hour: props.schedule?.interval_hour ?? 8,
|
||||
minute: props.schedule?.interval_minute ?? 0,
|
||||
})
|
||||
|
||||
const saving = ref(false)
|
||||
const errorMsg = ref<string | null>(null)
|
||||
|
||||
// ── original snapshot (for dirty detection) ──────────────────────────────────
|
||||
|
||||
const origMode = props.schedule?.mode ?? 'days'
|
||||
const origDays = new Set(_days)
|
||||
const origDefaultTime: TimeValue = { ..._base }
|
||||
const origHasDefaultDeadline = props.schedule?.default_has_deadline ?? true
|
||||
const origExceptions = new Map<number, TimeValue>(
|
||||
Array.from(_exMap.entries()).map(([k, v]) => [k, { ...v }]),
|
||||
)
|
||||
const origIntervalDays = Math.max(1, props.schedule?.interval_days ?? 1)
|
||||
const origAnchorDate = props.schedule?.anchor_date || getTodayISO()
|
||||
const origHasDeadline = props.schedule?.interval_has_deadline ?? true
|
||||
const origIntervalTime: TimeValue = {
|
||||
hour: props.schedule?.interval_hour ?? 8,
|
||||
minute: props.schedule?.interval_minute ?? 0,
|
||||
}
|
||||
const origEnabled = props.schedule?.enabled ?? true
|
||||
|
||||
// ── computed ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const sortedSelectedDays = computed(() => Array.from(selectedDays.value).sort((a, b) => a - b))
|
||||
|
||||
const todayISO = getTodayISO()
|
||||
|
||||
const nextOccurrence = computed((): string | null => {
|
||||
if (!anchorDate.value) return null
|
||||
const [y, m, d] = anchorDate.value.split('-').map(Number)
|
||||
const anchor = new Date(y, m - 1, d, 0, 0, 0, 0)
|
||||
if (isNaN(anchor.getTime())) return null
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
|
||||
const fmt = (dt: Date) =>
|
||||
dt.toLocaleDateString('en-US', {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})
|
||||
|
||||
// Find the first hit date strictly after today
|
||||
for (let i = 1; i <= 365; i++) {
|
||||
const cur = new Date(anchor.getTime() + i * 86_400_000)
|
||||
const diffDays = Math.round((cur.getTime() - anchor.getTime()) / 86_400_000)
|
||||
if (diffDays % intervalDays.value === 0 && cur >= today) {
|
||||
return fmt(cur)
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
const formatDefaultTime = computed(() => {
|
||||
const h = defaultTime.value.hour % 12 || 12
|
||||
const m = String(defaultTime.value.minute).padStart(2, '0')
|
||||
const period = defaultTime.value.hour < 12 ? 'AM' : 'PM'
|
||||
return `${String(h).padStart(2, '0')}:${m} ${period}`
|
||||
})
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function toggleDay(dayIdx: number) {
|
||||
if (selectedDays.value.has(dayIdx)) {
|
||||
selectedDays.value.delete(dayIdx)
|
||||
exceptions.value.delete(dayIdx)
|
||||
// trigger reactivity
|
||||
selectedDays.value = new Set(selectedDays.value)
|
||||
} else {
|
||||
const next = new Set(selectedDays.value)
|
||||
next.add(dayIdx)
|
||||
selectedDays.value = next
|
||||
}
|
||||
}
|
||||
|
||||
function onDefaultTimeChanged(val: TimeValue) {
|
||||
defaultTime.value = val
|
||||
}
|
||||
|
||||
function decrementInterval() {
|
||||
if (intervalDays.value > 1) intervalDays.value--
|
||||
}
|
||||
|
||||
function incrementInterval() {
|
||||
if (intervalDays.value < 7) intervalDays.value++
|
||||
}
|
||||
|
||||
function toggleDeadline() {
|
||||
hasDeadline.value = !hasDeadline.value
|
||||
}
|
||||
|
||||
// ── validation + dirty ───────────────────────────────────────────────────────
|
||||
|
||||
const isValid = computed(() => {
|
||||
// days mode: 0 selections is valid — means "unschedule" (always active)
|
||||
if (mode.value === 'interval') {
|
||||
return intervalDays.value >= 1 && intervalDays.value <= 7
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
const isDirty = computed(() => {
|
||||
if (scheduleEnabled.value !== origEnabled) return true
|
||||
if (mode.value !== origMode) return true
|
||||
|
||||
if (mode.value === 'days') {
|
||||
// Selected days set changed
|
||||
if (selectedDays.value.size !== origDays.size) return true
|
||||
for (const d of selectedDays.value) {
|
||||
if (!origDays.has(d)) return true
|
||||
}
|
||||
// Default time changed
|
||||
if (
|
||||
defaultTime.value.hour !== origDefaultTime.hour ||
|
||||
defaultTime.value.minute !== origDefaultTime.minute
|
||||
)
|
||||
return true
|
||||
// Default deadline toggled
|
||||
if (hasDefaultDeadline.value !== origHasDefaultDeadline) return true
|
||||
// Exceptions changed
|
||||
if (exceptions.value.size !== origExceptions.size) return true
|
||||
for (const [day, t] of exceptions.value) {
|
||||
const orig = origExceptions.get(day)
|
||||
if (!orig || orig.hour !== t.hour || orig.minute !== t.minute) return true
|
||||
}
|
||||
return false
|
||||
} else {
|
||||
if (intervalDays.value !== origIntervalDays) return true
|
||||
if (anchorDate.value !== origAnchorDate) return true
|
||||
if (hasDeadline.value !== origHasDeadline) return true
|
||||
if (
|
||||
hasDeadline.value &&
|
||||
(intervalTime.value.hour !== origIntervalTime.hour ||
|
||||
intervalTime.value.minute !== origIntervalTime.minute)
|
||||
)
|
||||
return true
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
// ── save ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function save() {
|
||||
if (!isValid.value || !isDirty.value || saving.value) return
|
||||
errorMsg.value = null
|
||||
saving.value = true
|
||||
|
||||
let res: Response
|
||||
if (mode.value === 'days' && selectedDays.value.size === 0) {
|
||||
// 0 days = remove schedule entirely → chore becomes always active
|
||||
res = await deleteChoreSchedule(props.childId, props.task.id)
|
||||
} else if (mode.value === 'days') {
|
||||
const day_configs: DayConfig[] = Array.from(selectedDays.value).map((day) => {
|
||||
const override = exceptions.value.get(day)
|
||||
return {
|
||||
day,
|
||||
hour: override?.hour ?? defaultTime.value.hour,
|
||||
minute: override?.minute ?? defaultTime.value.minute,
|
||||
}
|
||||
})
|
||||
res = await setChoreSchedule(props.childId, props.task.id, {
|
||||
mode: 'days',
|
||||
enabled: scheduleEnabled.value,
|
||||
day_configs,
|
||||
default_hour: defaultTime.value.hour,
|
||||
default_minute: defaultTime.value.minute,
|
||||
default_has_deadline: hasDefaultDeadline.value,
|
||||
})
|
||||
} else {
|
||||
res = await setChoreSchedule(props.childId, props.task.id, {
|
||||
mode: 'interval',
|
||||
enabled: scheduleEnabled.value,
|
||||
interval_days: intervalDays.value,
|
||||
anchor_date: anchorDate.value,
|
||||
interval_has_deadline: hasDeadline.value,
|
||||
interval_hour: intervalTime.value.hour,
|
||||
interval_minute: intervalTime.value.minute,
|
||||
})
|
||||
}
|
||||
saving.value = false
|
||||
|
||||
if (res.ok) {
|
||||
emit('saved')
|
||||
} else {
|
||||
const { msg } = await parseErrorResponse(res)
|
||||
errorMsg.value = msg
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mode-toggle {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.2rem;
|
||||
}
|
||||
|
||||
.mode-btn {
|
||||
flex: 1;
|
||||
padding: 0.55rem 0.4rem;
|
||||
border: 1.5px solid var(--primary, #667eea);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--primary, #667eea);
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.15s,
|
||||
color 0.15s;
|
||||
}
|
||||
|
||||
.mode-btn.active {
|
||||
background: var(--btn-primary, #667eea);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Days form */
|
||||
.days-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.day-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.chip {
|
||||
min-width: 2.4rem;
|
||||
padding: 0.4rem 0.5rem;
|
||||
border: 1.5px solid var(--primary, #667eea);
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: var(--primary, #667eea);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
transition:
|
||||
background 0.15s,
|
||||
color 0.15s;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.chip:hover {
|
||||
background: color-mix(in srgb, var(--btn-primary, #667eea) 12%, transparent);
|
||||
}
|
||||
|
||||
.chip.active {
|
||||
background: var(--btn-primary, #667eea);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.default-deadline-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.exception-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.exception-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.exception-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.exception-day-name {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
color: var(--secondary, #7257b3);
|
||||
min-width: 75px;
|
||||
}
|
||||
|
||||
.default-label {
|
||||
font-size: 0.85rem;
|
||||
color: var(--form-label, #888);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.link-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0.6rem 0.5rem;
|
||||
margin: -0.6rem -0.5rem;
|
||||
min-height: 44px;
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: var(--primary, #667eea);
|
||||
text-decoration: underline;
|
||||
font-family: inherit;
|
||||
transition: opacity 0.12s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.link-btn:hover {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.reset-btn {
|
||||
color: var(--error, #e53e3e);
|
||||
}
|
||||
|
||||
/* Interval form */
|
||||
.interval-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.interval-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.interval-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.interval-time-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--secondary, #7257b3);
|
||||
}
|
||||
|
||||
.stepper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
border: 1.5px solid var(--primary, #667eea);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stepper-btn {
|
||||
min-width: 2.75rem;
|
||||
min-height: 2.75rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--primary, #667eea);
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s;
|
||||
font-family: inherit;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.stepper-btn:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--btn-primary, #667eea) 12%, transparent);
|
||||
}
|
||||
|
||||
.stepper-btn:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.stepper-value {
|
||||
min-width: 1.6rem;
|
||||
text-align: center;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--secondary, #7257b3);
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
|
||||
.anytime-label {
|
||||
font-size: 0.85rem;
|
||||
color: var(--form-label, #888);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.next-occurrence-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.next-occurrence-label {
|
||||
font-size: 0.85rem;
|
||||
color: var(--form-label, #888);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Error */
|
||||
.error-msg {
|
||||
color: var(--error, #e53e3e);
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/* Actions */
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--btn-primary, #667eea);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 0.55rem 1.4rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── Enable/Disable Toggle ────────────────────── */
|
||||
|
||||
.schedule-toggle-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.6rem;
|
||||
margin-bottom: 1rem;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.toggle-label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--form-label, #444);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.toggle-track {
|
||||
position: relative;
|
||||
width: 48px;
|
||||
height: 24px;
|
||||
border-radius: 999px;
|
||||
border: none;
|
||||
background: var(--form-input-border, #cbd5e1);
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease;
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toggle-track.on {
|
||||
background: var(--btn-primary, #667eea);
|
||||
}
|
||||
|
||||
.toggle-thumb {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.18);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.toggle-track.on .toggle-thumb {
|
||||
transform: translateX(24px);
|
||||
}
|
||||
|
||||
/* ── Dim wrapper when paused ──────────────────── */
|
||||
|
||||
.schedule-body {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.schedule-body.disabled {
|
||||
opacity: 0.45;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
430
frontend/src/components/shared/ScrollingList.vue
Normal file
430
frontend/src/components/shared/ScrollingList.vue
Normal file
@@ -0,0 +1,430 @@
|
||||
<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
|
||||
sortFn?: (a: any, b: any) => number
|
||||
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)
|
||||
|
||||
let fetchGeneration = 0
|
||||
|
||||
const fetchItems = async () => {
|
||||
const thisGeneration = ++fetchGeneration
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const resp = await fetch(fetchUrl.value)
|
||||
if (thisGeneration !== fetchGeneration) return
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
const data = await resp.json()
|
||||
if (thisGeneration !== fetchGeneration) return
|
||||
// Try to use 'tasks', 'reward_status', or 'items' as fallback
|
||||
let itemList = data[props.itemKey || 'items'] || []
|
||||
if (props.filterFn) itemList = itemList.filter(props.filterFn)
|
||||
if (props.sortFn) itemList = [...itemList].sort(props.sortFn)
|
||||
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
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
if (thisGeneration !== fetchGeneration) return
|
||||
// Re-assign to trigger Vue reactivity for image URLs set on raw objects
|
||||
items.value = [...itemList]
|
||||
} catch (err) {
|
||||
if (thisGeneration !== fetchGeneration) return
|
||||
error.value = err instanceof Error ? err.message : 'Failed to fetch items'
|
||||
items.value = []
|
||||
console.error('Error fetching items:', err)
|
||||
} finally {
|
||||
if (thisGeneration === fetchGeneration) {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
await fetchItems()
|
||||
}
|
||||
|
||||
const scrollToItem = async (itemId: string) => {
|
||||
await nextTick()
|
||||
const card = itemRefs.value[itemId]
|
||||
if (card) {
|
||||
;(card as HTMLElement).scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||
}
|
||||
await centerItem(itemId)
|
||||
}
|
||||
|
||||
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',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
refresh,
|
||||
items,
|
||||
centerItem,
|
||||
scrollToItem,
|
||||
})
|
||||
|
||||
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 },
|
||||
]"
|
||||
:data-item-id="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>
|
||||
32
frontend/src/components/shared/StatusMessage.vue
Normal file
32
frontend/src/components/shared/StatusMessage.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
loading: boolean
|
||||
error: string | null
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
<div v-else-if="error" class="error">Error: {{ error }}</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.loading {
|
||||
color: var(--loading-color);
|
||||
min-height: 200px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--error-color);
|
||||
min-height: 100px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
</style>
|
||||
6
frontend/src/components/shared/SuccessMessage.vue
Normal file
6
frontend/src/components/shared/SuccessMessage.vue
Normal 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>
|
||||
261
frontend/src/components/shared/TimePickerPopover.vue
Normal file
261
frontend/src/components/shared/TimePickerPopover.vue
Normal file
@@ -0,0 +1,261 @@
|
||||
<template>
|
||||
<div ref="rootEl" class="time-picker-popover">
|
||||
<button type="button" class="time-display" @click="toggleOpen" :class="{ open: isOpen }">
|
||||
{{ formattedTime }}
|
||||
</button>
|
||||
|
||||
<div v-if="isOpen" class="popover-panel">
|
||||
<div class="columns">
|
||||
<!-- Hours column -->
|
||||
<div class="col">
|
||||
<div class="col-header">Hour</div>
|
||||
<div class="col-scroll">
|
||||
<button
|
||||
v-for="h in HOURS"
|
||||
:key="h"
|
||||
type="button"
|
||||
class="col-item"
|
||||
:class="{ selected: displayHour === h }"
|
||||
@click="selectHour(h)"
|
||||
>
|
||||
{{ h }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-divider">:</div>
|
||||
|
||||
<!-- Minutes column -->
|
||||
<div class="col">
|
||||
<div class="col-header">Min</div>
|
||||
<div class="col-scroll">
|
||||
<button
|
||||
v-for="m in MINUTES"
|
||||
:key="m"
|
||||
type="button"
|
||||
class="col-item"
|
||||
:class="{ selected: props.modelValue.minute === m }"
|
||||
@click="selectMinute(m)"
|
||||
>
|
||||
{{ String(m).padStart(2, '0') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AM/PM column -->
|
||||
<div class="col ampm-col">
|
||||
<div class="col-header">AM/PM</div>
|
||||
<div class="col-scroll">
|
||||
<button
|
||||
type="button"
|
||||
class="col-item"
|
||||
:class="{ selected: period === 'AM' }"
|
||||
@click="selectPeriod('AM')"
|
||||
>
|
||||
AM
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="col-item"
|
||||
:class="{ selected: period === 'PM' }"
|
||||
@click="selectPeriod('PM')"
|
||||
>
|
||||
PM
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onUnmounted } from 'vue'
|
||||
|
||||
interface TimeValue {
|
||||
hour: number // 0–23
|
||||
minute: number // 0, 15, 30, 45
|
||||
}
|
||||
|
||||
const props = defineProps<{ modelValue: TimeValue }>()
|
||||
const emit = defineEmits<{ (e: 'update:modelValue', val: TimeValue): void }>()
|
||||
|
||||
const HOURS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]
|
||||
const MINUTES = [0, 15, 30, 45]
|
||||
|
||||
const rootEl = ref<HTMLElement | null>(null)
|
||||
const isOpen = ref(false)
|
||||
|
||||
const period = computed(() => (props.modelValue.hour < 12 ? 'AM' : 'PM'))
|
||||
|
||||
const displayHour = computed(() => {
|
||||
const h = props.modelValue.hour % 12
|
||||
return h === 0 ? 12 : h
|
||||
})
|
||||
|
||||
const formattedTime = computed(() => {
|
||||
const h = displayHour.value
|
||||
const m = String(props.modelValue.minute).padStart(2, '0')
|
||||
return `${String(h).padStart(2, '0')}:${m} ${period.value}`
|
||||
})
|
||||
|
||||
function toggleOpen() {
|
||||
if (isOpen.value) {
|
||||
close()
|
||||
} else {
|
||||
isOpen.value = true
|
||||
document.addEventListener('mousedown', onDocumentMouseDown, { capture: true })
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
isOpen.value = false
|
||||
document.removeEventListener('mousedown', onDocumentMouseDown, { capture: true })
|
||||
}
|
||||
|
||||
function onDocumentMouseDown(e: MouseEvent) {
|
||||
if (rootEl.value && !rootEl.value.contains(e.target as Node)) {
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
function selectHour(h: number) {
|
||||
// h is 1–12 display; convert to 0–23 preserving period
|
||||
const isPm = period.value === 'PM'
|
||||
let hour24: number
|
||||
if (h === 12) {
|
||||
hour24 = isPm ? 12 : 0
|
||||
} else {
|
||||
hour24 = isPm ? h + 12 : h
|
||||
}
|
||||
emit('update:modelValue', { hour: hour24, minute: props.modelValue.minute })
|
||||
}
|
||||
|
||||
function selectMinute(m: number) {
|
||||
emit('update:modelValue', { hour: props.modelValue.hour, minute: m })
|
||||
}
|
||||
|
||||
function selectPeriod(p: 'AM' | 'PM') {
|
||||
const currentPeriod = period.value
|
||||
if (p === currentPeriod) return
|
||||
const newHour = p === 'PM' ? props.modelValue.hour + 12 : props.modelValue.hour - 12
|
||||
emit('update:modelValue', { hour: newHour, minute: props.modelValue.minute })
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('mousedown', onDocumentMouseDown, { capture: true })
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.time-picker-popover {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.time-display {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.35rem 0.75rem;
|
||||
border: 1.5px solid var(--kebab-menu-border, #bcc1c9);
|
||||
border-radius: 8px;
|
||||
background: var(--modal-bg, #fff);
|
||||
color: var(--secondary, #7257b3);
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
box-shadow 0.15s;
|
||||
white-space: nowrap;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.time-display:hover,
|
||||
.time-display.open {
|
||||
border-color: var(--btn-primary, #667eea);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--btn-primary, #667eea) 20%, transparent);
|
||||
}
|
||||
|
||||
.popover-panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 0;
|
||||
z-index: 200;
|
||||
background: var(--modal-bg, #fff);
|
||||
border: 1.5px solid var(--kebab-menu-border, #bcc1c9);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 18px var(--modal-shadow, rgba(0, 0, 0, 0.15));
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.columns {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
min-width: 3rem;
|
||||
}
|
||||
|
||||
.ampm-col {
|
||||
min-width: 3.2rem;
|
||||
}
|
||||
|
||||
.col-header {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
color: var(--primary, #667eea);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
margin-bottom: 0.35rem;
|
||||
padding: 0 0.15rem;
|
||||
}
|
||||
|
||||
.col-scroll {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.18rem;
|
||||
max-height: 12rem;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.col-item {
|
||||
padding: 0.3rem 0.4rem;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--secondary, #7257b3);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.12s,
|
||||
color 0.12s;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.col-item:hover {
|
||||
background: var(--menu-item-hover-bg, rgba(102, 126, 234, 0.1));
|
||||
}
|
||||
|
||||
.col-item.selected {
|
||||
background: var(--btn-primary, #667eea);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.col-divider {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: var(--secondary, #7257b3);
|
||||
padding-top: 1.6rem;
|
||||
align-self: flex-start;
|
||||
}
|
||||
</style>
|
||||
161
frontend/src/components/shared/TimeSelector.vue
Normal file
161
frontend/src/components/shared/TimeSelector.vue
Normal file
@@ -0,0 +1,161 @@
|
||||
<template>
|
||||
<div class="time-selector">
|
||||
<!-- Hour column -->
|
||||
<div class="time-col">
|
||||
<button class="arrow-btn" @click="incrementHour" aria-label="Increase hour">▲</button>
|
||||
<div class="time-value">{{ displayHour }}</div>
|
||||
<button class="arrow-btn" @click="decrementHour" aria-label="Decrease hour">▼</button>
|
||||
</div>
|
||||
|
||||
<div class="time-separator">:</div>
|
||||
|
||||
<!-- Minute column -->
|
||||
<div class="time-col">
|
||||
<button class="arrow-btn" @click="incrementMinute" aria-label="Increase minute">▲</button>
|
||||
<div class="time-value">{{ displayMinute }}</div>
|
||||
<button class="arrow-btn" @click="decrementMinute" aria-label="Decrease minute">▼</button>
|
||||
</div>
|
||||
|
||||
<!-- AM/PM toggle -->
|
||||
<button class="ampm-btn" @click="toggleAmPm">{{ period }}</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface TimeValue {
|
||||
hour: number // 0–23 (24h)
|
||||
minute: number // 0, 15, 30, or 45
|
||||
}
|
||||
|
||||
const props = defineProps<{ modelValue: TimeValue }>()
|
||||
const emit = defineEmits<{ (e: 'update:modelValue', val: TimeValue): void }>()
|
||||
|
||||
const MINUTES = [0, 15, 30, 45]
|
||||
|
||||
// ── display helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
const period = computed(() => (props.modelValue.hour < 12 ? 'AM' : 'PM'))
|
||||
|
||||
const displayHour = computed(() => {
|
||||
const h = props.modelValue.hour % 12
|
||||
return String(h === 0 ? 12 : h)
|
||||
})
|
||||
|
||||
const displayMinute = computed(() => String(props.modelValue.minute).padStart(2, '0'))
|
||||
|
||||
// ── mutations ────────────────────────────────────────────────────────────────
|
||||
|
||||
function incrementHour() {
|
||||
const next = (props.modelValue.hour + 1) % 24
|
||||
emit('update:modelValue', { hour: next, minute: props.modelValue.minute })
|
||||
}
|
||||
|
||||
function decrementHour() {
|
||||
const next = (props.modelValue.hour - 1 + 24) % 24
|
||||
emit('update:modelValue', { hour: next, minute: props.modelValue.minute })
|
||||
}
|
||||
|
||||
function incrementMinute() {
|
||||
const idx = MINUTES.indexOf(props.modelValue.minute)
|
||||
if (idx === MINUTES.length - 1) {
|
||||
// Wrap minute to 0 and carry into next hour
|
||||
const nextHour = (props.modelValue.hour + 1) % 24
|
||||
emit('update:modelValue', { hour: nextHour, minute: 0 })
|
||||
} else {
|
||||
emit('update:modelValue', { hour: props.modelValue.hour, minute: MINUTES[idx + 1] })
|
||||
}
|
||||
}
|
||||
|
||||
function decrementMinute() {
|
||||
const idx = MINUTES.indexOf(props.modelValue.minute)
|
||||
if (idx === 0) {
|
||||
// Wrap minute to 45 and borrow from previous hour
|
||||
const prevHour = (props.modelValue.hour - 1 + 24) % 24
|
||||
emit('update:modelValue', { hour: prevHour, minute: 45 })
|
||||
} else {
|
||||
emit('update:modelValue', { hour: props.modelValue.hour, minute: MINUTES[idx - 1] })
|
||||
}
|
||||
}
|
||||
|
||||
function toggleAmPm() {
|
||||
const next = props.modelValue.hour < 12 ? props.modelValue.hour + 12 : props.modelValue.hour - 12
|
||||
emit('update:modelValue', { hour: next, minute: props.modelValue.minute })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.time-selector {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.time-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.arrow-btn {
|
||||
width: 1.8rem;
|
||||
height: 1.8rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: 1px solid var(--kebab-menu-border, #bcc1c9);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
color: var(--primary, #667eea);
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.arrow-btn:hover {
|
||||
background: var(--menu-item-hover-bg, rgba(102, 126, 234, 0.08));
|
||||
}
|
||||
|
||||
.time-value {
|
||||
width: 1.8rem;
|
||||
height: 1.8rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--secondary, #7257b3);
|
||||
border: 1.5px solid var(--kebab-menu-border, #bcc1c9);
|
||||
border-radius: 6px;
|
||||
background: var(--modal-bg, #fff);
|
||||
}
|
||||
|
||||
.time-separator {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--secondary, #7257b3);
|
||||
padding-bottom: 0.1rem;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.ampm-btn {
|
||||
width: 2.2rem;
|
||||
height: 1.8rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
border: 1.5px solid var(--btn-primary, #667eea);
|
||||
border-radius: 6px;
|
||||
background: var(--btn-primary, #667eea);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
margin-left: 0.35rem;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.ampm-btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
</style>
|
||||
131
frontend/src/components/shared/ToggleField.vue
Normal file
131
frontend/src/components/shared/ToggleField.vue
Normal file
@@ -0,0 +1,131 @@
|
||||
<template>
|
||||
<div class="toggle-field-row">
|
||||
<span class="toggle-field-label">{{ label }}</span>
|
||||
<button
|
||||
type="button"
|
||||
:class="['toggle-btn', { active: modelValue }]"
|
||||
:aria-pressed="modelValue"
|
||||
:disabled="disabled"
|
||||
@click="$emit('update:modelValue', !modelValue)"
|
||||
>
|
||||
<span class="toggle-knob" />
|
||||
</button>
|
||||
<button
|
||||
v-if="description"
|
||||
type="button"
|
||||
class="toggle-expand-btn"
|
||||
:aria-expanded="expanded"
|
||||
@click.stop="expanded = !expanded"
|
||||
:aria-label="expanded ? 'Collapse info' : 'Show info'"
|
||||
>
|
||||
<span class="toggle-chevron" :class="{ open: expanded }">›</span>
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="description && expanded" class="toggle-desc-text">{{ description }}</p>
|
||||
<p v-if="error" class="toggle-error">{{ error }}</p>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
defineProps<{
|
||||
label: string
|
||||
modelValue: boolean
|
||||
disabled?: boolean
|
||||
description?: string
|
||||
error?: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
}>()
|
||||
|
||||
const expanded = ref(false)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.toggle-field-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.toggle-field-label {
|
||||
flex: 1;
|
||||
font-size: 0.95rem;
|
||||
color: var(--form-label, #444);
|
||||
}
|
||||
|
||||
.toggle-expand-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted, #888);
|
||||
padding: 0 0.15rem;
|
||||
font-size: 1.2rem;
|
||||
line-height: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.toggle-chevron {
|
||||
display: inline-block;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.toggle-chevron.open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.toggle-desc-text {
|
||||
font-size: 0.83rem;
|
||||
color: var(--text-muted, #666);
|
||||
margin: 0.3rem 0 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.toggle-error {
|
||||
font-size: 0.9rem;
|
||||
color: var(--error, #e53e3e);
|
||||
margin: 0.2rem 0 0;
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
flex-shrink: 0;
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
border-radius: 12px;
|
||||
border: none;
|
||||
background: var(--form-input-border, #ccc);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transition: background 0.2s;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.toggle-btn.active {
|
||||
background: var(--btn-primary, #4a90e2);
|
||||
}
|
||||
|
||||
.toggle-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.toggle-knob {
|
||||
display: block;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: white;
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
transition: left 0.2s;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toggle-btn.active .toggle-knob {
|
||||
left: 23px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import DateInputField from '../DateInputField.vue'
|
||||
|
||||
describe('DateInputField', () => {
|
||||
it('renders a native date input', () => {
|
||||
const w = mount(DateInputField, { props: { modelValue: '2026-03-01' } })
|
||||
const input = w.find('input[type="date"]')
|
||||
expect(input.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('reflects modelValue as the hidden input value', () => {
|
||||
const w = mount(DateInputField, { props: { modelValue: '2026-04-15' } })
|
||||
const input = w.find<HTMLInputElement>('input[type="date"]')
|
||||
expect((input.element as HTMLInputElement).value).toBe('2026-04-15')
|
||||
})
|
||||
|
||||
it('emits update:modelValue with the new ISO string when changed', async () => {
|
||||
const w = mount(DateInputField, { props: { modelValue: '2026-03-01' } })
|
||||
const input = w.find<HTMLInputElement>('input[type="date"]')
|
||||
await input.setValue('2026-05-20')
|
||||
expect(w.emitted('update:modelValue')).toBeTruthy()
|
||||
expect(w.emitted('update:modelValue')![0]).toEqual(['2026-05-20'])
|
||||
})
|
||||
|
||||
it('does not emit when no change is triggered', () => {
|
||||
const w = mount(DateInputField, { props: { modelValue: '2026-03-01' } })
|
||||
expect(w.emitted('update:modelValue')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('passes min prop to the native input', () => {
|
||||
const w = mount(DateInputField, {
|
||||
props: { modelValue: '2026-03-10', min: '2026-02-26' },
|
||||
})
|
||||
const input = w.find<HTMLInputElement>('input[type="date"]')
|
||||
expect((input.element as HTMLInputElement).min).toBe('2026-02-26')
|
||||
})
|
||||
|
||||
it('renders without min prop when not provided', () => {
|
||||
const w = mount(DateInputField, { props: { modelValue: '2026-03-01' } })
|
||||
const input = w.find<HTMLInputElement>('input[type="date"]')
|
||||
expect((input.element as HTMLInputElement).min).toBe('')
|
||||
})
|
||||
|
||||
it('shows "Select date" placeholder when modelValue is empty', () => {
|
||||
const w = mount(DateInputField, { props: { modelValue: '' } })
|
||||
expect(w.find('.date-display-btn').text()).toBe('Select date')
|
||||
})
|
||||
|
||||
it('shows formatted date with weekday when modelValue is set', () => {
|
||||
const w = mount(DateInputField, { props: { modelValue: '2026-03-05' } })
|
||||
// Thu, Mar 5, 2026
|
||||
const text = w.find('.date-display-btn').text()
|
||||
expect(text).toContain('Thu')
|
||||
expect(text).toContain('Mar')
|
||||
expect(text).toContain('5')
|
||||
expect(text).toContain('2026')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import EntityEditForm from '../EntityEditForm.vue'
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: vi.fn(() => ({
|
||||
push: vi.fn(),
|
||||
back: vi.fn(),
|
||||
})),
|
||||
}))
|
||||
|
||||
describe('EntityEditForm', () => {
|
||||
it('keeps Create disabled when required number field is empty', async () => {
|
||||
const wrapper = mount(EntityEditForm, {
|
||||
props: {
|
||||
entityLabel: 'Child',
|
||||
fields: [
|
||||
{ name: 'name', label: 'Name', type: 'text', required: true },
|
||||
{ name: 'age', label: 'Age', type: 'number', required: true, min: 0, max: 120 },
|
||||
],
|
||||
initialData: {
|
||||
name: '',
|
||||
age: null,
|
||||
},
|
||||
isEdit: false,
|
||||
loading: false,
|
||||
requireDirty: false,
|
||||
},
|
||||
})
|
||||
|
||||
const nameInput = wrapper.find('#name')
|
||||
const ageInput = wrapper.find('#age')
|
||||
|
||||
await nameInput.setValue('Sam')
|
||||
await ageInput.setValue('')
|
||||
|
||||
const submitButton = wrapper.find('button[type="submit"]')
|
||||
expect(submitButton.text()).toBe('Create')
|
||||
expect((submitButton.element as HTMLButtonElement).disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('enables Create when required Name and Age are both valid', async () => {
|
||||
const wrapper = mount(EntityEditForm, {
|
||||
props: {
|
||||
entityLabel: 'Child',
|
||||
fields: [
|
||||
{ name: 'name', label: 'Name', type: 'text', required: true },
|
||||
{ name: 'age', label: 'Age', type: 'number', required: true, min: 0, max: 120 },
|
||||
],
|
||||
initialData: {
|
||||
name: '',
|
||||
age: null,
|
||||
},
|
||||
isEdit: false,
|
||||
loading: false,
|
||||
requireDirty: false,
|
||||
},
|
||||
})
|
||||
|
||||
const nameInput = wrapper.find('#name')
|
||||
const ageInput = wrapper.find('#age')
|
||||
|
||||
await nameInput.setValue('Sam')
|
||||
await ageInput.setValue('8')
|
||||
|
||||
const submitButton = wrapper.find('button[type="submit"]')
|
||||
expect(submitButton.text()).toBe('Create')
|
||||
expect((submitButton.element as HTMLButtonElement).disabled).toBe(false)
|
||||
})
|
||||
})
|
||||
486
frontend/src/components/shared/__tests__/LoginButton.spec.ts
Normal file
486
frontend/src/components/shared/__tests__/LoginButton.spec.ts
Normal file
@@ -0,0 +1,486 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { mount, VueWrapper } from '@vue/test-utils'
|
||||
import { nextTick } from 'vue'
|
||||
import LoginButton from '../LoginButton.vue'
|
||||
import { authenticateParent, logoutParent } from '../../../stores/auth'
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: vi.fn(() => ({ push: vi.fn() })),
|
||||
}))
|
||||
|
||||
// Mock imageCache module
|
||||
vi.mock('@/common/imageCache', () => ({
|
||||
getCachedImageUrl: vi.fn(async (imageId: string) => `blob:mock-url-${imageId}`),
|
||||
revokeImageUrl: vi.fn(),
|
||||
revokeAllImageUrls: vi.fn(),
|
||||
}))
|
||||
|
||||
// Create real Vue refs for isParentAuthenticated and isParentPersistent using vi.hoisted.
|
||||
// Real Vue refs are required so Vue templates auto-unwrap them correctly in v-if conditions.
|
||||
const { isParentAuthenticatedRef, isParentPersistentRef } = vi.hoisted(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { ref } = require('vue')
|
||||
return {
|
||||
isParentAuthenticatedRef: ref(false),
|
||||
isParentPersistentRef: ref(false),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../../../stores/auth', () => ({
|
||||
authenticateParent: vi.fn(),
|
||||
isParentAuthenticated: isParentAuthenticatedRef,
|
||||
isParentPersistent: isParentPersistentRef,
|
||||
logoutParent: vi.fn(),
|
||||
logoutUser: vi.fn(),
|
||||
consumePendingReturnUrl: vi.fn(() => null),
|
||||
hasPendingReturnUrl: vi.fn(() => false),
|
||||
clearPendingReturnUrl: vi.fn(),
|
||||
}))
|
||||
|
||||
global.fetch = vi.fn()
|
||||
|
||||
describe('LoginButton', () => {
|
||||
let wrapper: VueWrapper<any>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
isParentAuthenticatedRef.value = false
|
||||
isParentPersistentRef.value = false
|
||||
;(global.fetch as any).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
image_id: 'test-image-id',
|
||||
first_name: 'John',
|
||||
email: 'john@example.com',
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (wrapper) {
|
||||
wrapper.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
describe('Avatar Rendering', () => {
|
||||
it('renders avatar with image when image_id is available', async () => {
|
||||
wrapper = mount(LoginButton)
|
||||
await nextTick()
|
||||
// Wait for fetchUserProfile to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
// Component should be mounted and functional
|
||||
expect(wrapper.exists()).toBe(true)
|
||||
// Should have attempted to load user profile with credentials
|
||||
expect(global.fetch).toHaveBeenCalledWith('/api/user/profile', {
|
||||
credentials: 'include',
|
||||
})
|
||||
})
|
||||
|
||||
it('renders avatar with initial when no image_id', async () => {
|
||||
;(global.fetch as any).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ first_name: 'Jane' }),
|
||||
})
|
||||
|
||||
wrapper = mount(LoginButton)
|
||||
await nextTick()
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
const avatarText = wrapper.find('.avatar-text')
|
||||
expect(avatarText.exists()).toBe(true)
|
||||
expect(avatarText.text()).toBe('J')
|
||||
})
|
||||
|
||||
it('renders ? when no first_name available', async () => {
|
||||
;(global.fetch as any).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({}),
|
||||
})
|
||||
|
||||
wrapper = mount(LoginButton)
|
||||
await nextTick()
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
const avatarText = wrapper.find('.avatar-text')
|
||||
expect(avatarText.exists()).toBe(true)
|
||||
expect(avatarText.text()).toBe('?')
|
||||
})
|
||||
|
||||
it('shows loading state initially', async () => {
|
||||
wrapper = mount(LoginButton)
|
||||
const loading = wrapper.find('.avatar-text')
|
||||
expect(loading.exists()).toBe(true)
|
||||
expect(loading.text()).toBe('...')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Dropdown Interactions', () => {
|
||||
beforeEach(async () => {
|
||||
isParentAuthenticatedRef.value = true
|
||||
wrapper = mount(LoginButton)
|
||||
await nextTick()
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
})
|
||||
|
||||
it('opens dropdown on click when authenticated', async () => {
|
||||
const button = wrapper.find('.avatar-btn')
|
||||
await button.trigger('click')
|
||||
await nextTick()
|
||||
|
||||
const dropdown = wrapper.find('.dropdown-menu')
|
||||
expect(dropdown.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('closes dropdown on Escape key', async () => {
|
||||
const button = wrapper.find('.avatar-btn')
|
||||
await button.trigger('click')
|
||||
await nextTick()
|
||||
|
||||
let dropdown = wrapper.find('.dropdown-menu')
|
||||
expect(dropdown.exists()).toBe(true)
|
||||
|
||||
await button.trigger('keydown', { key: 'Escape' })
|
||||
await nextTick()
|
||||
|
||||
dropdown = wrapper.find('.dropdown-menu')
|
||||
expect(dropdown.exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('closes dropdown on outside click', async () => {
|
||||
const button = wrapper.find('.avatar-btn')
|
||||
await button.trigger('click')
|
||||
await nextTick()
|
||||
|
||||
let dropdown = wrapper.find('.dropdown-menu')
|
||||
expect(dropdown.exists()).toBe(true)
|
||||
|
||||
// Simulate outside click by directly calling the handler
|
||||
const outsideClick = new MouseEvent('mousedown', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
document.dispatchEvent(outsideClick)
|
||||
await nextTick()
|
||||
|
||||
dropdown = wrapper.find('.dropdown-menu')
|
||||
expect(dropdown.exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Keyboard Navigation', () => {
|
||||
beforeEach(async () => {
|
||||
isParentAuthenticatedRef.value = true
|
||||
wrapper = mount(LoginButton)
|
||||
await nextTick()
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
})
|
||||
|
||||
it('opens dropdown on Enter key', async () => {
|
||||
const button = wrapper.find('.avatar-btn')
|
||||
await button.trigger('keydown', { key: 'Enter' })
|
||||
await nextTick()
|
||||
|
||||
const dropdown = wrapper.find('.dropdown-menu')
|
||||
expect(dropdown.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('opens dropdown on Space key', async () => {
|
||||
const button = wrapper.find('.avatar-btn')
|
||||
await button.trigger('keydown', { key: ' ' })
|
||||
await nextTick()
|
||||
|
||||
const dropdown = wrapper.find('.dropdown-menu')
|
||||
expect(dropdown.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('navigates menu items with arrow keys', async () => {
|
||||
const button = wrapper.find('.avatar-btn')
|
||||
await button.trigger('click')
|
||||
await nextTick()
|
||||
|
||||
const dropdown = wrapper.find('.dropdown-menu')
|
||||
expect(dropdown.exists()).toBe(true)
|
||||
|
||||
// First item should be focused
|
||||
let menuItems = wrapper.findAll('.menu-item')
|
||||
expect(menuItems[0].attributes('aria-selected')).toBe('true')
|
||||
|
||||
// Press down arrow
|
||||
await button.trigger('keydown', { key: 'ArrowDown' })
|
||||
await nextTick()
|
||||
|
||||
menuItems = wrapper.findAll('.menu-item')
|
||||
expect(menuItems[1].attributes('aria-selected')).toBe('true')
|
||||
})
|
||||
})
|
||||
|
||||
describe('ARIA Attributes', () => {
|
||||
it('has correct ARIA attributes', async () => {
|
||||
// Note: Due to vi.mock hoisting limitations, isParentAuthenticated
|
||||
// value is set when the mock is first created
|
||||
wrapper = mount(LoginButton)
|
||||
await nextTick()
|
||||
|
||||
const button = wrapper.find('.avatar-btn')
|
||||
// Should have ar haspopup regardless of auth state
|
||||
expect(button.attributes('aria-haspopup')).toBe('menu')
|
||||
expect(button.attributes('aria-expanded')).toBe('false')
|
||||
// aria-label will vary based on the initial mock state
|
||||
expect(button.attributes('aria-label')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('has correct ARIA attributes when authenticated', async () => {
|
||||
isParentAuthenticatedRef.value = true
|
||||
wrapper = mount(LoginButton)
|
||||
await nextTick()
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
const button = wrapper.find('.avatar-btn')
|
||||
expect(button.attributes('aria-label')).toBe('Parent menu')
|
||||
expect(button.attributes('aria-haspopup')).toBe('menu')
|
||||
expect(button.attributes('aria-expanded')).toBe('false')
|
||||
})
|
||||
|
||||
it('updates aria-expanded when dropdown opens', async () => {
|
||||
isParentAuthenticatedRef.value = true
|
||||
wrapper = mount(LoginButton)
|
||||
await nextTick()
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
const button = wrapper.find('.avatar-btn')
|
||||
await button.trigger('click')
|
||||
await nextTick()
|
||||
|
||||
expect(button.attributes('aria-expanded')).toBe('true')
|
||||
})
|
||||
|
||||
it('menu items have correct roles', async () => {
|
||||
isParentAuthenticatedRef.value = true
|
||||
wrapper = mount(LoginButton)
|
||||
await nextTick()
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
const button = wrapper.find('.avatar-btn')
|
||||
await button.trigger('click')
|
||||
await nextTick()
|
||||
|
||||
const dropdown = wrapper.find('.dropdown-menu')
|
||||
expect(dropdown.attributes('role')).toBe('menu')
|
||||
|
||||
const menuItems = wrapper.findAll('.menu-item')
|
||||
menuItems.forEach((item) => {
|
||||
expect(item.attributes('role')).toBe('menuitem')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Mode Logic', () => {
|
||||
it('button click handler works correctly', async () => {
|
||||
// Due to vi.mock limitations with reactivity, we test that
|
||||
// click handler is wired up correctly
|
||||
wrapper = mount(LoginButton, {
|
||||
global: {
|
||||
mocks: {
|
||||
$router: {
|
||||
push: vi.fn(),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
await nextTick()
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
const button = wrapper.find('.avatar-btn')
|
||||
await button.trigger('click')
|
||||
await nextTick()
|
||||
|
||||
// Verify button is clickable and doesn't throw
|
||||
expect(button.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('dropdown visibility controlled by auth state and open/close', async () => {
|
||||
//Test dropdown behavior when authenticated
|
||||
isParentAuthenticatedRef.value = true
|
||||
wrapper = mount(LoginButton)
|
||||
await nextTick()
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
// Initially closed
|
||||
let dropdown = wrapper.find('.dropdown-menu')
|
||||
expect(dropdown.exists()).toBe(false)
|
||||
|
||||
// Click to open
|
||||
const button = wrapper.find('.avatar-btn')
|
||||
await button.trigger('click')
|
||||
await nextTick()
|
||||
|
||||
dropdown = wrapper.find('.dropdown-menu')
|
||||
expect(dropdown.exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Focus Ring', () => {
|
||||
it('shows focus ring on keyboard focus', async () => {
|
||||
wrapper = mount(LoginButton)
|
||||
await nextTick()
|
||||
|
||||
const button = wrapper.find('.avatar-btn')
|
||||
await button.trigger('focus')
|
||||
await nextTick()
|
||||
|
||||
// Check for focus ring styles
|
||||
expect(button.classes()).toContain('avatar-btn')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Stub Icons', () => {
|
||||
it('renders stub icons for menu items', async () => {
|
||||
isParentAuthenticatedRef.value = true
|
||||
wrapper = mount(LoginButton)
|
||||
await nextTick()
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
const button = wrapper.find('.avatar-btn')
|
||||
await button.trigger('click')
|
||||
await nextTick()
|
||||
|
||||
const stubs = wrapper.findAll('.menu-icon-stub')
|
||||
expect(stubs.length).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('PIN Modal - checkbox and persistent mode', () => {
|
||||
beforeEach(async () => {
|
||||
isParentAuthenticatedRef.value = false
|
||||
wrapper = mount(LoginButton)
|
||||
await nextTick()
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
})
|
||||
|
||||
it('checkbox is unchecked by default when modal opens', async () => {
|
||||
// Open modal by triggering the open-login event path
|
||||
// Mock has-pin response
|
||||
;(global.fetch as any)
|
||||
.mockResolvedValueOnce({ ok: true, json: async () => ({ has_pin: true }) })
|
||||
.mockResolvedValueOnce({ ok: true, json: async () => ({ valid: true }) })
|
||||
|
||||
const vm = wrapper.vm as any
|
||||
await vm.open()
|
||||
await nextTick()
|
||||
|
||||
const checkbox = wrapper.find('.stay-checkbox')
|
||||
expect(checkbox.exists()).toBe(true)
|
||||
expect((checkbox.element as HTMLInputElement).checked).toBe(false)
|
||||
})
|
||||
|
||||
it('submitting with checkbox checked calls authenticateParent(true)', async () => {
|
||||
const { authenticateParent } = await import('../../../stores/auth')
|
||||
;(global.fetch as any)
|
||||
.mockResolvedValueOnce({ ok: true, json: async () => ({ has_pin: true }) })
|
||||
.mockResolvedValueOnce({ ok: true, json: async () => ({ valid: true }) })
|
||||
|
||||
const vm = wrapper.vm as any
|
||||
await vm.open()
|
||||
await nextTick()
|
||||
|
||||
const checkbox = wrapper.find('.stay-checkbox')
|
||||
await checkbox.setValue(true)
|
||||
await nextTick()
|
||||
|
||||
const pinInput = wrapper.find('.pin-input')
|
||||
await pinInput.setValue('1234')
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await nextTick()
|
||||
|
||||
expect(authenticateParent).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('submitting without checking checkbox calls authenticateParent(false)', async () => {
|
||||
const { authenticateParent } = await import('../../../stores/auth')
|
||||
;(global.fetch as any)
|
||||
.mockResolvedValueOnce({ ok: true, json: async () => ({ has_pin: true }) })
|
||||
.mockResolvedValueOnce({ ok: true, json: async () => ({ valid: true }) })
|
||||
|
||||
const vm = wrapper.vm as any
|
||||
await vm.open()
|
||||
await nextTick()
|
||||
|
||||
const pinInput = wrapper.find('.pin-input')
|
||||
await pinInput.setValue('1234')
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await nextTick()
|
||||
|
||||
expect(authenticateParent).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Lock badge visibility', () => {
|
||||
it('lock badge is hidden when not authenticated', async () => {
|
||||
isParentAuthenticatedRef.value = false
|
||||
isParentPersistentRef.value = false
|
||||
wrapper = mount(LoginButton)
|
||||
await nextTick()
|
||||
|
||||
const badge = wrapper.find('.persistent-badge')
|
||||
expect(badge.exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('lock badge is hidden when authenticated but non-persistent', async () => {
|
||||
isParentAuthenticatedRef.value = true
|
||||
isParentPersistentRef.value = false
|
||||
wrapper = mount(LoginButton)
|
||||
await nextTick()
|
||||
|
||||
const badge = wrapper.find('.persistent-badge')
|
||||
expect(badge.exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('lock badge is visible when authenticated and persistent', async () => {
|
||||
isParentAuthenticatedRef.value = true
|
||||
isParentPersistentRef.value = true
|
||||
wrapper = mount(LoginButton)
|
||||
await nextTick()
|
||||
|
||||
const badge = wrapper.find('.persistent-badge')
|
||||
expect(badge.exists()).toBe(true)
|
||||
expect(badge.text()).toBe('🔒')
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Email Display', () => {
|
||||
it('displays email in dropdown header when available', async () => {
|
||||
isParentAuthenticatedRef.value = true
|
||||
wrapper = mount(LoginButton)
|
||||
await nextTick()
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
const button = wrapper.find('.avatar-btn')
|
||||
await button.trigger('click')
|
||||
await nextTick()
|
||||
|
||||
const email = wrapper.find('.dropdown-user-email')
|
||||
expect(email.exists()).toBe(true)
|
||||
expect(email.text()).toBe('john@example.com')
|
||||
})
|
||||
|
||||
it('does not display email element when email is not available', async () => {
|
||||
;(global.fetch as any).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ image_id: 'test-image-id', first_name: 'Jane' }),
|
||||
})
|
||||
|
||||
isParentAuthenticatedRef.value = true
|
||||
wrapper = mount(LoginButton)
|
||||
await nextTick()
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
const button = wrapper.find('.avatar-btn')
|
||||
await button.trigger('click')
|
||||
await nextTick()
|
||||
|
||||
const email = wrapper.find('.dropdown-user-email')
|
||||
expect(email.exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
432
frontend/src/components/shared/__tests__/ScrollingList.spec.ts
Normal file
432
frontend/src/components/shared/__tests__/ScrollingList.spec.ts
Normal file
@@ -0,0 +1,432 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { mount, VueWrapper } from '@vue/test-utils'
|
||||
import { nextTick } from 'vue'
|
||||
import ScrollingList from '../ScrollingList.vue'
|
||||
|
||||
// Mock image cache
|
||||
vi.mock('@/common/imageCache', () => ({
|
||||
getCachedImageUrl: vi.fn((id: string) => Promise.resolve(`/cached/${id}.png`)),
|
||||
revokeAllImageUrls: vi.fn(),
|
||||
}))
|
||||
|
||||
global.fetch = vi.fn()
|
||||
|
||||
describe('ScrollingList', () => {
|
||||
let wrapper: VueWrapper<any>
|
||||
|
||||
const defaultProps = {
|
||||
title: 'Test List',
|
||||
fetchBaseUrl: '/api/test',
|
||||
ids: ['item-1', 'item-2'],
|
||||
itemKey: 'items',
|
||||
}
|
||||
|
||||
const mockItems = [
|
||||
{ id: 'item-1', name: 'Item One', points: 10, image_id: 'img1' },
|
||||
{ id: 'item-2', name: 'Item Two', points: 20, image_id: 'img2' },
|
||||
]
|
||||
|
||||
const mockItemsWithOverride = [
|
||||
{ id: 'item-1', name: 'Item One', points: 10, image_id: 'img1', custom_value: 15 },
|
||||
{ id: 'item-2', name: 'Item Two', points: 20, image_id: 'img2', custom_value: null },
|
||||
]
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Mock fetch responses
|
||||
;(global.fetch as any).mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ items: mockItems }),
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (wrapper) {
|
||||
wrapper.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
describe('Component Mounting', () => {
|
||||
it('renders with title', async () => {
|
||||
wrapper = mount(ScrollingList, { props: defaultProps })
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('h3').text()).toBe('Test List')
|
||||
})
|
||||
|
||||
it('fetches items on mount', async () => {
|
||||
wrapper = mount(ScrollingList, { props: defaultProps })
|
||||
await nextTick()
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith('/api/test?ids=item-1,item-2')
|
||||
})
|
||||
|
||||
it('displays loading state initially', () => {
|
||||
wrapper = mount(ScrollingList, { props: defaultProps })
|
||||
|
||||
expect(wrapper.find('.loading').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('displays items after loading', async () => {
|
||||
wrapper = mount(ScrollingList, { props: defaultProps })
|
||||
await nextTick()
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
expect(wrapper.find('.loading').exists()).toBe(false)
|
||||
expect(wrapper.findAll('.item-card').length).toBe(2)
|
||||
})
|
||||
|
||||
it('displays empty message when no items', async () => {
|
||||
;(global.fetch as any).mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ items: [] }),
|
||||
})
|
||||
|
||||
wrapper = mount(ScrollingList, { props: defaultProps })
|
||||
await nextTick()
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
expect(wrapper.find('.empty').text()).toContain('No Test List')
|
||||
})
|
||||
|
||||
it('displays error message on fetch failure', async () => {
|
||||
;(global.fetch as any).mockRejectedValue(new Error('Network error'))
|
||||
|
||||
wrapper = mount(ScrollingList, { props: defaultProps })
|
||||
await nextTick()
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
expect(wrapper.find('.error').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Override Badge Display', () => {
|
||||
it('shows override badge when custom_value exists and isParentAuthenticated is true', async () => {
|
||||
;(global.fetch as any).mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ items: mockItemsWithOverride }),
|
||||
})
|
||||
|
||||
wrapper = mount(ScrollingList, { props: { ...defaultProps, isParentAuthenticated: true } })
|
||||
await nextTick()
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
const badges = wrapper.findAll('.override-badge')
|
||||
expect(badges.length).toBe(1) // Only item-1 has custom_value
|
||||
expect(badges[0].text()).toBe('⭐')
|
||||
})
|
||||
|
||||
it('does not show badge when custom_value is null', async () => {
|
||||
;(global.fetch as any).mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ items: mockItems }),
|
||||
})
|
||||
|
||||
wrapper = mount(ScrollingList, { props: { ...defaultProps, isParentAuthenticated: true } })
|
||||
await nextTick()
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
expect(wrapper.findAll('.override-badge').length).toBe(0)
|
||||
})
|
||||
|
||||
it('does not show badge when custom_value is undefined', async () => {
|
||||
const itemsWithUndefined = [{ id: 'item-1', name: 'Item One', points: 10 }]
|
||||
|
||||
;(global.fetch as any).mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ items: itemsWithUndefined }),
|
||||
})
|
||||
|
||||
wrapper = mount(ScrollingList, { props: { ...defaultProps, isParentAuthenticated: true } })
|
||||
await nextTick()
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
expect(wrapper.findAll('.override-badge').length).toBe(0)
|
||||
})
|
||||
|
||||
it('does not show badge in child mode even when custom_value exists', async () => {
|
||||
;(global.fetch as any).mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ items: mockItemsWithOverride }),
|
||||
})
|
||||
|
||||
wrapper = mount(ScrollingList, { props: { ...defaultProps, isParentAuthenticated: false } })
|
||||
await nextTick()
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
expect(wrapper.findAll('.override-badge').length).toBe(0)
|
||||
})
|
||||
|
||||
it('does not show badge when isParentAuthenticated is undefined', async () => {
|
||||
;(global.fetch as any).mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ items: mockItemsWithOverride }),
|
||||
})
|
||||
|
||||
wrapper = mount(ScrollingList, { props: defaultProps })
|
||||
await nextTick()
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
expect(wrapper.findAll('.override-badge').length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Two-Step Click Interaction', () => {
|
||||
beforeEach(async () => {
|
||||
wrapper = mount(ScrollingList, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
enableEdit: false, // No edit button for basic click test
|
||||
},
|
||||
})
|
||||
await nextTick()
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
})
|
||||
|
||||
it('emits item-ready on first click', async () => {
|
||||
const cards = wrapper.findAll('.item-card')
|
||||
await cards[0].trigger('click')
|
||||
|
||||
expect(wrapper.emitted('item-ready')).toBeTruthy()
|
||||
expect(wrapper.emitted('item-ready')![0]).toEqual(['item-1'])
|
||||
})
|
||||
|
||||
it('emits trigger-item on second click of same item', async () => {
|
||||
const cards = wrapper.findAll('.item-card')
|
||||
|
||||
// First click - select item
|
||||
await cards[0].trigger('click')
|
||||
await nextTick()
|
||||
|
||||
// Update prop to simulate parent setting readyItemId
|
||||
await wrapper.setProps({ readyItemId: 'item-1' })
|
||||
await nextTick()
|
||||
|
||||
// Second click - trigger item
|
||||
await cards[0].trigger('click')
|
||||
|
||||
expect(wrapper.emitted('trigger-item')).toBeTruthy()
|
||||
expect(wrapper.emitted('trigger-item')![0][0].id).toBe('item-1')
|
||||
})
|
||||
|
||||
it('resets ready state after triggering', async () => {
|
||||
const cards = wrapper.findAll('.item-card')
|
||||
|
||||
// First click
|
||||
await cards[0].trigger('click')
|
||||
await wrapper.setProps({ readyItemId: 'item-1' })
|
||||
|
||||
// Second click
|
||||
await cards[0].trigger('click')
|
||||
|
||||
const itemReadyEvents = wrapper.emitted('item-ready')
|
||||
expect(itemReadyEvents![itemReadyEvents!.length - 1]).toEqual([''])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edit Button Display', () => {
|
||||
it('shows edit button only for readyItemId when enableEdit is true', async () => {
|
||||
wrapper = mount(ScrollingList, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
enableEdit: true,
|
||||
readyItemId: 'item-1',
|
||||
},
|
||||
})
|
||||
await nextTick()
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
const editButtons = wrapper.findAll('.edit-button')
|
||||
expect(editButtons.length).toBe(1)
|
||||
})
|
||||
|
||||
it('does not show edit button when enableEdit is false', async () => {
|
||||
wrapper = mount(ScrollingList, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
enableEdit: false,
|
||||
readyItemId: 'item-1',
|
||||
},
|
||||
})
|
||||
await nextTick()
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
expect(wrapper.findAll('.edit-button').length).toBe(0)
|
||||
})
|
||||
|
||||
it('does not show edit button when readyItemId is null', async () => {
|
||||
wrapper = mount(ScrollingList, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
enableEdit: true,
|
||||
readyItemId: null,
|
||||
},
|
||||
})
|
||||
await nextTick()
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
expect(wrapper.findAll('.edit-button').length).toBe(0)
|
||||
})
|
||||
|
||||
it('emits edit-item when edit button is clicked', async () => {
|
||||
wrapper = mount(ScrollingList, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
enableEdit: true,
|
||||
readyItemId: 'item-1',
|
||||
},
|
||||
})
|
||||
await nextTick()
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
const editButton = wrapper.find('.edit-button')
|
||||
await editButton.trigger('click')
|
||||
|
||||
expect(wrapper.emitted('edit-item')).toBeTruthy()
|
||||
expect(wrapper.emitted('edit-item')![0][0].id).toBe('item-1')
|
||||
})
|
||||
|
||||
it('resets ready state after edit button click', async () => {
|
||||
wrapper = mount(ScrollingList, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
enableEdit: true,
|
||||
readyItemId: 'item-1',
|
||||
},
|
||||
})
|
||||
await nextTick()
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
const editButton = wrapper.find('.edit-button')
|
||||
await editButton.trigger('click')
|
||||
|
||||
const itemReadyEvents = wrapper.emitted('item-ready')
|
||||
expect(itemReadyEvents![itemReadyEvents!.length - 1]).toEqual([''])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Filter Function', () => {
|
||||
it('filters items using provided filterFn', async () => {
|
||||
const filterFn = (item: any) => item.points > 15
|
||||
|
||||
wrapper = mount(ScrollingList, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
filterFn,
|
||||
},
|
||||
})
|
||||
await nextTick()
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
expect(wrapper.findAll('.item-card').length).toBe(1) // Only item-2 with 20 points
|
||||
})
|
||||
})
|
||||
|
||||
describe('Refresh Method', () => {
|
||||
it('exposes refresh method', async () => {
|
||||
wrapper = mount(ScrollingList, { props: defaultProps })
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.vm.refresh).toBeDefined()
|
||||
expect(typeof wrapper.vm.refresh).toBe('function')
|
||||
})
|
||||
|
||||
it('refetches items when refresh is called', async () => {
|
||||
wrapper = mount(ScrollingList, { props: defaultProps })
|
||||
await nextTick()
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
vi.clearAllMocks()
|
||||
|
||||
await wrapper.vm.refresh()
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith('/api/test?ids=item-1,item-2')
|
||||
})
|
||||
|
||||
it('discards stale fetch when concurrent refreshes occur', async () => {
|
||||
const { getCachedImageUrl } = await import('@/common/imageCache')
|
||||
|
||||
const staleItems = [{ id: 'stale-1', name: 'Stale', points: 1, image_id: 'stale-img' }]
|
||||
const freshItems = [{ id: 'fresh-1', name: 'Fresh', points: 2, image_id: 'fresh-img' }]
|
||||
|
||||
wrapper = mount(ScrollingList, { props: defaultProps })
|
||||
await nextTick()
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
// Set up two concurrent fetches: first one is slow, second one is fast
|
||||
let resolveFirst: (v: any) => void
|
||||
const firstFetchPromise = new Promise((r) => {
|
||||
resolveFirst = r
|
||||
})
|
||||
|
||||
vi.clearAllMocks()
|
||||
;(global.fetch as any)
|
||||
.mockReturnValueOnce(firstFetchPromise) // slow call
|
||||
.mockResolvedValueOnce({
|
||||
// fast call
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ items: freshItems }),
|
||||
})
|
||||
|
||||
// Start first refresh (slow)
|
||||
const first = wrapper.vm.refresh()
|
||||
// Start second refresh (fast) — should supersede the first
|
||||
const second = wrapper.vm.refresh()
|
||||
|
||||
// Resolve the fast call first (already resolved via mockResolvedValueOnce)
|
||||
await second
|
||||
await nextTick()
|
||||
|
||||
// Items should be from the fast (second) call
|
||||
expect(wrapper.vm.items).toHaveLength(1)
|
||||
expect(wrapper.vm.items[0].name).toBe('Fresh')
|
||||
|
||||
// Now resolve the slow (stale) call
|
||||
resolveFirst!({ ok: true, json: () => Promise.resolve({ items: staleItems }) })
|
||||
await first
|
||||
await nextTick()
|
||||
|
||||
// Items should still be from the fast (second) call — stale result discarded
|
||||
expect(wrapper.vm.items).toHaveLength(1)
|
||||
expect(wrapper.vm.items[0].name).toBe('Fresh')
|
||||
// Image was loaded for fresh item, not stale
|
||||
expect(getCachedImageUrl).toHaveBeenCalledWith('fresh-img')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Image Loading', () => {
|
||||
it('loads images for items with image_id', async () => {
|
||||
wrapper = mount(ScrollingList, { props: defaultProps })
|
||||
await nextTick()
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
const { getCachedImageUrl } = await import('@/common/imageCache')
|
||||
expect(getCachedImageUrl).toHaveBeenCalledWith('img1')
|
||||
expect(getCachedImageUrl).toHaveBeenCalledWith('img2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Custom Item Classes', () => {
|
||||
it('applies custom classes from getItemClass prop', async () => {
|
||||
const getItemClass = (item: any) => ({
|
||||
good: item.points > 15,
|
||||
bad: item.points <= 15,
|
||||
})
|
||||
|
||||
wrapper = mount(ScrollingList, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
getItemClass,
|
||||
},
|
||||
})
|
||||
await nextTick()
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
const cards = wrapper.findAll('.item-card')
|
||||
expect(cards[0].classes()).toContain('bad') // item-1 with 10 points
|
||||
expect(cards[1].classes()).toContain('good') // item-2 with 20 points
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user