Added beginning of login functionality
This commit is contained in:
@@ -1,6 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div v-if="message" class="error-message" aria-live="polite">{{ message }}</div>
|
|
||||||
</template>
|
|
||||||
<script setup lang="ts">
|
|
||||||
defineProps<{ message: string }>()
|
|
||||||
</script>
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="modal-backdrop">
|
|
||||||
<div class="modal-dialog">
|
|
||||||
<slot />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<script setup lang="ts">
|
|
||||||
// No script content needed unless you want to add props or logic
|
|
||||||
</script>
|
|
||||||
<style scoped>
|
|
||||||
.modal-backdrop {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100vw;
|
|
||||||
height: 100vh;
|
|
||||||
background: rgba(0, 0, 0, 0.35);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: 1000;
|
|
||||||
}
|
|
||||||
.modal-dialog {
|
|
||||||
background: #fff;
|
|
||||||
padding: 2rem;
|
|
||||||
border-radius: 12px;
|
|
||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
|
|
||||||
max-width: 340px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div v-if="message" class="success-message" aria-live="polite">{{ message }}</div>
|
|
||||||
</template>
|
|
||||||
<script setup lang="ts">
|
|
||||||
defineProps<{ message: string }>()
|
|
||||||
</script>
|
|
||||||
@@ -1,603 +0,0 @@
|
|||||||
<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'
|
|
||||||
|
|
||||||
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>
|
|
||||||
<div v-if="children.length === 0" class="no-message">
|
|
||||||
<div>No children</div>
|
|
||||||
<div class="sub-message">
|
|
||||||
<template v-if="!isParentAuthenticated">
|
|
||||||
<button class="sign-in-btn" @click="eventBus.emit('open-login')">Sign in</button> to
|
|
||||||
create a child
|
|
||||||
</template>
|
|
||||||
<span v-else><button class="sign-in-btn" @click="createChild">Create</button> a child</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- confirmation modal -->
|
|
||||||
<div
|
|
||||||
v-if="confirmDeleteVisible"
|
|
||||||
class="modal-backdrop"
|
|
||||||
@click.self="confirmDeleteVisible = false"
|
|
||||||
>
|
|
||||||
<div class="modal">
|
|
||||||
<h3>Delete child?</h3>
|
|
||||||
<p>Are you sure you want to permanently delete this child?</p>
|
|
||||||
<div class="actions">
|
|
||||||
<button
|
|
||||||
class="btn btn-secondary"
|
|
||||||
@click="
|
|
||||||
() => {
|
|
||||||
confirmDeleteVisible = false
|
|
||||||
deletingChildId = null
|
|
||||||
}
|
|
||||||
"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-danger" @click="performDelete" :disabled="deleting">
|
|
||||||
{{ deleting ? 'Deleting…' : 'Delete' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Add Child button (FAB) -->
|
|
||||||
<button v-if="isParentAuthenticated" class="fab" @click="createChild" aria-label="Add Child">
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
* {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
color: white;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
font-size: 2.5rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading,
|
|
||||||
.empty {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
min-height: 200px;
|
|
||||||
color: white;
|
|
||||||
font-size: 1.2rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* modal */
|
|
||||||
.modal-backdrop {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.45);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: 1200;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal {
|
|
||||||
background: var(--modal-bg);
|
|
||||||
color: var(--modal-text);
|
|
||||||
padding: 1.25rem;
|
|
||||||
border-radius: 10px;
|
|
||||||
width: 360px;
|
|
||||||
max-width: calc(100% - 32px);
|
|
||||||
box-shadow: var(--modal-shadow);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal h3 {
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
font-size: 1.05rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.points {
|
|
||||||
font-size: 1.05rem;
|
|
||||||
color: var(--points-color);
|
|
||||||
margin-top: 0.4rem;
|
|
||||||
font-weight: 600;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sign-in-btn {
|
|
||||||
background: var(--sign-in-btn-bg);
|
|
||||||
color: var(--sign-in-btn-color);
|
|
||||||
border: 2px solid var(--sign-in-btn-border);
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-weight: 600;
|
|
||||||
padding: 0.2rem 0.5rem;
|
|
||||||
margin-right: 0.1rem;
|
|
||||||
cursor: pointer;
|
|
||||||
transition:
|
|
||||||
background 0.18s,
|
|
||||||
color 0.18s;
|
|
||||||
}
|
|
||||||
.sign-in-btn:hover {
|
|
||||||
background: var(--sign-in-btn-hover-bg);
|
|
||||||
color: var(--sign-in-btn-hover-color);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,365 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { ref, onMounted, onBeforeUnmount, nextTick, defineProps, defineEmits, computed } from 'vue'
|
|
||||||
import { getCachedImageUrl } from '../common/imageCache'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
modelValue?: string | null // selected image id or local-upload
|
|
||||||
imageType?: number // 1 or 2, default 1
|
|
||||||
}>()
|
|
||||||
const emit = defineEmits(['update:modelValue', 'add-image'])
|
|
||||||
|
|
||||||
const fileInput = ref<HTMLInputElement | null>(null)
|
|
||||||
const localImageUrl = ref<string | null>(null)
|
|
||||||
const showCamera = ref(false)
|
|
||||||
const cameraStream = ref<MediaStream | null>(null)
|
|
||||||
const cameraVideo = ref<HTMLVideoElement | null>(null)
|
|
||||||
const cameraError = ref<string | null>(null)
|
|
||||||
const capturedImageUrl = ref<string | null>(null)
|
|
||||||
const cameraFile = ref<File | null>(null)
|
|
||||||
|
|
||||||
const availableImages = ref<{ id: string; url: string }[]>([])
|
|
||||||
const loadingImages = ref(false)
|
|
||||||
|
|
||||||
const typeParam = computed(() => props.imageType ?? 1)
|
|
||||||
|
|
||||||
const selectImage = (id: string | undefined) => {
|
|
||||||
if (!id) {
|
|
||||||
console.warn('selectImage called with null id')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
emit('update:modelValue', id)
|
|
||||||
}
|
|
||||||
|
|
||||||
const addFromLocal = () => {
|
|
||||||
fileInput.value?.click()
|
|
||||||
}
|
|
||||||
|
|
||||||
const onFileChange = async (event: Event) => {
|
|
||||||
const files = (event.target as HTMLInputElement).files
|
|
||||||
if (files && files.length > 0) {
|
|
||||||
const file = files[0]
|
|
||||||
if (localImageUrl.value) URL.revokeObjectURL(localImageUrl.value)
|
|
||||||
const { blob, url } = await resizeImageFile(file, 512)
|
|
||||||
localImageUrl.value = url
|
|
||||||
updateLocalImage(url, new File([blob], file.name, { type: 'image/png' }))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
if (localImageUrl.value) URL.revokeObjectURL(localImageUrl.value)
|
|
||||||
})
|
|
||||||
|
|
||||||
const addFromCamera = async () => {
|
|
||||||
cameraError.value = null
|
|
||||||
capturedImageUrl.value = null
|
|
||||||
showCamera.value = true
|
|
||||||
await nextTick()
|
|
||||||
try {
|
|
||||||
const stream = await navigator.mediaDevices.getUserMedia({ video: true })
|
|
||||||
cameraStream.value = stream
|
|
||||||
if (cameraVideo.value) {
|
|
||||||
cameraVideo.value.srcObject = stream
|
|
||||||
await cameraVideo.value.play()
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
cameraError.value = 'Unable to access camera'
|
|
||||||
cameraStream.value = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const takePhoto = async () => {
|
|
||||||
if (!cameraVideo.value) return
|
|
||||||
const canvas = document.createElement('canvas')
|
|
||||||
canvas.width = cameraVideo.value.videoWidth
|
|
||||||
canvas.height = cameraVideo.value.videoHeight
|
|
||||||
const ctx = canvas.getContext('2d')
|
|
||||||
if (ctx) {
|
|
||||||
ctx.drawImage(cameraVideo.value, 0, 0, canvas.width, canvas.height)
|
|
||||||
const dataUrl = canvas.toDataURL('image/png')
|
|
||||||
capturedImageUrl.value = dataUrl
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const confirmPhoto = async () => {
|
|
||||||
if (capturedImageUrl.value) {
|
|
||||||
if (localImageUrl.value) URL.revokeObjectURL(localImageUrl.value)
|
|
||||||
// Convert dataURL to Blob
|
|
||||||
const res = await fetch(capturedImageUrl.value)
|
|
||||||
const originalBlob = await res.blob()
|
|
||||||
const { blob, url } = await resizeImageFile(originalBlob, 512)
|
|
||||||
localImageUrl.value = url
|
|
||||||
cameraFile.value = new File([blob], 'camera.png', { type: 'image/png' })
|
|
||||||
updateLocalImage(url, cameraFile.value)
|
|
||||||
}
|
|
||||||
closeCamera()
|
|
||||||
}
|
|
||||||
|
|
||||||
const retakePhoto = async () => {
|
|
||||||
capturedImageUrl.value = null
|
|
||||||
cameraFile.value = null
|
|
||||||
await resumeCameraStream()
|
|
||||||
}
|
|
||||||
|
|
||||||
const closeCamera = () => {
|
|
||||||
showCamera.value = false
|
|
||||||
capturedImageUrl.value = null
|
|
||||||
if (cameraStream.value) {
|
|
||||||
cameraStream.value.getTracks().forEach((track) => track.stop())
|
|
||||||
cameraStream.value = null
|
|
||||||
}
|
|
||||||
if (cameraVideo.value) {
|
|
||||||
cameraVideo.value.srcObject = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const resumeCameraStream = async () => {
|
|
||||||
await nextTick()
|
|
||||||
if (cameraVideo.value && cameraStream.value) {
|
|
||||||
cameraVideo.value.srcObject = cameraStream.value
|
|
||||||
try {
|
|
||||||
await cameraVideo.value.play()
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch images on mount
|
|
||||||
onMounted(async () => {
|
|
||||||
loadingImages.value = true
|
|
||||||
try {
|
|
||||||
const resp = await fetch(`/api/image/list?type=${typeParam.value}`)
|
|
||||||
if (resp.ok) {
|
|
||||||
const data = await resp.json()
|
|
||||||
const ids = data.ids || []
|
|
||||||
// Fetch URLs for each image id using the cache
|
|
||||||
const urls = await Promise.all(
|
|
||||||
ids.map(async (id: string) => {
|
|
||||||
try {
|
|
||||||
const url = await getCachedImageUrl(id)
|
|
||||||
return { id, url }
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
const images = urls.filter(Boolean) as { id: string; url: string }[]
|
|
||||||
// Move the selected image to the front if it exists
|
|
||||||
if (props.modelValue) {
|
|
||||||
const idx = images.findIndex((img) => img.id === props.modelValue)
|
|
||||||
if (idx > 0) {
|
|
||||||
const [selected] = images.splice(idx, 1)
|
|
||||||
images.unshift(selected)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
availableImages.value = images
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
// Optionally handle error
|
|
||||||
} finally {
|
|
||||||
loadingImages.value = false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
async function resizeImageFile(
|
|
||||||
file: File | Blob,
|
|
||||||
maxDim = 512,
|
|
||||||
): Promise<{ blob: Blob; url: string }> {
|
|
||||||
const img = new window.Image()
|
|
||||||
const url = URL.createObjectURL(file)
|
|
||||||
img.src = url
|
|
||||||
await new Promise((resolve) => {
|
|
||||||
img.onload = resolve
|
|
||||||
})
|
|
||||||
|
|
||||||
let { width, height } = img
|
|
||||||
if (width > maxDim || height > maxDim) {
|
|
||||||
if (width > height) {
|
|
||||||
height = Math.round((height * maxDim) / width)
|
|
||||||
width = maxDim
|
|
||||||
} else {
|
|
||||||
width = Math.round((width * maxDim) / height)
|
|
||||||
height = maxDim
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const canvas = document.createElement('canvas')
|
|
||||||
canvas.width = width
|
|
||||||
canvas.height = height
|
|
||||||
const ctx = canvas.getContext('2d')
|
|
||||||
ctx?.drawImage(img, 0, 0, width, height)
|
|
||||||
const blob: Blob = await new Promise((resolve) => canvas.toBlob((b) => resolve(b!), 'image/png'))
|
|
||||||
URL.revokeObjectURL(url)
|
|
||||||
return { blob, url: URL.createObjectURL(blob) }
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateLocalImage(url: string, file: File) {
|
|
||||||
const idx = availableImages.value.findIndex((img) => img.id === 'local-upload')
|
|
||||||
if (idx === -1) {
|
|
||||||
availableImages.value.unshift({ id: 'local-upload', url })
|
|
||||||
} else {
|
|
||||||
availableImages.value[idx].url = url
|
|
||||||
}
|
|
||||||
emit('add-image', { id: 'local-upload', url, file })
|
|
||||||
emit('update:modelValue', 'local-upload')
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div>
|
|
||||||
<div class="image-scroll">
|
|
||||||
<div v-if="loadingImages" class="loading-images">Loading images...</div>
|
|
||||||
<div v-else class="image-list">
|
|
||||||
<img
|
|
||||||
v-for="img in availableImages"
|
|
||||||
:key="img.id"
|
|
||||||
:src="img.url"
|
|
||||||
class="selectable-image"
|
|
||||||
:class="{ selected: modelValue === img.id }"
|
|
||||||
:alt="`Image ${img.id}`"
|
|
||||||
@click="selectImage(img.id)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
ref="fileInput"
|
|
||||||
type="file"
|
|
||||||
accept=".png,.jpg,.jpeg,.gif,image/png,image/jpeg,image/gif"
|
|
||||||
capture="environment"
|
|
||||||
style="display: none"
|
|
||||||
@change="onFileChange"
|
|
||||||
/>
|
|
||||||
<div class="image-actions">
|
|
||||||
<button type="button" class="icon-btn" @click="addFromLocal" aria-label="Add from device">
|
|
||||||
<span class="icon">+</span>
|
|
||||||
</button>
|
|
||||||
<button type="button" class="icon-btn" @click="addFromCamera" aria-label="Add from camera">
|
|
||||||
<span class="icon">
|
|
||||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
|
||||||
<rect x="3" y="6" width="14" height="10" rx="2" stroke="#667eea" stroke-width="1.5" />
|
|
||||||
<circle cx="10" cy="11" r="3" stroke="#667eea" stroke-width="1.5" />
|
|
||||||
<rect x="7" y="3" width="6" height="3" rx="1" stroke="#667eea" stroke-width="1.5" />
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Camera modal -->
|
|
||||||
<div v-if="showCamera" class="modal-backdrop">
|
|
||||||
<div class="modal camera-modal">
|
|
||||||
<h3>Take a photo</h3>
|
|
||||||
<div v-if="cameraError" class="error">{{ cameraError }}</div>
|
|
||||||
<div v-else>
|
|
||||||
<div v-if="!capturedImageUrl">
|
|
||||||
<video ref="cameraVideo" autoplay playsinline class="camera-display"></video>
|
|
||||||
<div class="actions">
|
|
||||||
<button type="button" class="btn btn-primary" @click="takePhoto">Capture</button>
|
|
||||||
<button type="button" class="btn btn-secondary" @click="closeCamera">Cancel</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-else>
|
|
||||||
<img :src="capturedImageUrl" class="camera-display" alt="Preview" />
|
|
||||||
<div class="actions">
|
|
||||||
<button type="button" class="btn btn-primary" @click="confirmPhoto">Choose</button>
|
|
||||||
<button type="button" class="btn btn-secondary" @click="retakePhoto">Retake</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.image-scroll {
|
|
||||||
width: 100%;
|
|
||||||
margin: 0.7rem 0 0.2rem 0;
|
|
||||||
overflow-x: auto;
|
|
||||||
overflow-y: hidden;
|
|
||||||
padding-bottom: 0.2rem;
|
|
||||||
}
|
|
||||||
.image-list {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.7rem;
|
|
||||||
min-width: min-content;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
.selectable-image {
|
|
||||||
width: 64px;
|
|
||||||
height: 64px;
|
|
||||||
object-fit: cover;
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 2px solid var(--selectable-image-border);
|
|
||||||
background: var(--selectable-image-bg);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: border 0.18s;
|
|
||||||
}
|
|
||||||
.selectable-image:hover,
|
|
||||||
.selectable-image.selected {
|
|
||||||
border-color: var(--selectable-image-selected);
|
|
||||||
box-shadow: 0 0 0 2px #667eea55;
|
|
||||||
}
|
|
||||||
.loading-images {
|
|
||||||
color: var(--loading-text);
|
|
||||||
font-size: 0.98rem;
|
|
||||||
padding: 0.5rem 0;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 4rem;
|
|
||||||
justify-content: center;
|
|
||||||
margin-top: 1.2rem; /* Increased space below images */
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-btn {
|
|
||||||
background: var(--icon-btn-bg);
|
|
||||||
border: none;
|
|
||||||
border-radius: 50%;
|
|
||||||
width: 56px; /* Increased size */
|
|
||||||
height: 56px; /* Increased size */
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.18s;
|
|
||||||
font-size: 2.2rem; /* Bigger + icon */
|
|
||||||
color: var(--icon-btn-color);
|
|
||||||
box-shadow: var(--icon-btn-shadow);
|
|
||||||
}
|
|
||||||
.icon-btn svg {
|
|
||||||
width: 32px; /* Bigger camera icon */
|
|
||||||
height: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Camera modal styles */
|
|
||||||
.camera-modal {
|
|
||||||
position: fixed;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
background: var(--modal-bg);
|
|
||||||
border-radius: 12px;
|
|
||||||
box-shadow: var(--modal-shadow);
|
|
||||||
z-index: 1300;
|
|
||||||
width: 380px;
|
|
||||||
max-width: calc(100vw - 32px);
|
|
||||||
padding-bottom: 1.5rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.camera-display {
|
|
||||||
width: auto;
|
|
||||||
max-width: 100%;
|
|
||||||
max-height: 240px;
|
|
||||||
border-radius: 12px;
|
|
||||||
display: block;
|
|
||||||
margin-left: auto;
|
|
||||||
margin-right: auto;
|
|
||||||
object-fit: contain;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { ref, nextTick, onMounted, onUnmounted } from 'vue'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { eventBus } from '@/common/eventBus'
|
|
||||||
import { authenticateParent, isParentAuthenticated, logoutParent } from '../stores/auth'
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const show = ref(false)
|
|
||||||
const pin = ref('')
|
|
||||||
const error = ref('')
|
|
||||||
const pinInput = ref<HTMLInputElement | null>(null)
|
|
||||||
const dropdownOpen = ref(false)
|
|
||||||
|
|
||||||
const open = async () => {
|
|
||||||
pin.value = ''
|
|
||||||
error.value = ''
|
|
||||||
show.value = true
|
|
||||||
await nextTick()
|
|
||||||
pinInput.value?.focus()
|
|
||||||
}
|
|
||||||
|
|
||||||
const close = () => {
|
|
||||||
show.value = false
|
|
||||||
error.value = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
const submit = () => {
|
|
||||||
const isDigits = /^\d{4,6}$/.test(pin.value)
|
|
||||||
if (!isDigits) {
|
|
||||||
error.value = 'Enter 4–6 digits'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pin.value !== '1179') {
|
|
||||||
error.value = 'Incorrect PIN'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Authenticate parent and navigate
|
|
||||||
authenticateParent()
|
|
||||||
close()
|
|
||||||
router.push('/parent')
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleLogout = () => {
|
|
||||||
logoutParent()
|
|
||||||
router.push('/child')
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleDropdown() {
|
|
||||||
dropdownOpen.value = !dropdownOpen.value
|
|
||||||
}
|
|
||||||
|
|
||||||
async function signOut() {
|
|
||||||
try {
|
|
||||||
await fetch('/api/logout', { method: 'POST' })
|
|
||||||
logoutParent()
|
|
||||||
router.push('/auth')
|
|
||||||
} catch {
|
|
||||||
// Optionally show error
|
|
||||||
}
|
|
||||||
dropdownOpen.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
eventBus.on('open-login', open)
|
|
||||||
})
|
|
||||||
onUnmounted(() => {
|
|
||||||
eventBus.off('open-login', open)
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="login-button-root" style="position: relative">
|
|
||||||
<button v-if="!isParentAuthenticated" @click="open" aria-label="Parent login" class="login-btn">
|
|
||||||
Parent
|
|
||||||
</button>
|
|
||||||
<div v-else style="display: inline-block; position: relative">
|
|
||||||
<button
|
|
||||||
@click="toggleDropdown"
|
|
||||||
aria-label="Parent menu"
|
|
||||||
class="login-btn"
|
|
||||||
style="min-width: 80px"
|
|
||||||
>
|
|
||||||
Parent ▼
|
|
||||||
</button>
|
|
||||||
<div
|
|
||||||
v-if="dropdownOpen"
|
|
||||||
class="dropdown-menu"
|
|
||||||
style="
|
|
||||||
position: absolute;
|
|
||||||
right: 0;
|
|
||||||
top: 100%;
|
|
||||||
background: #fff;
|
|
||||||
border: 1px solid #e6e6e6;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
|
||||||
min-width: 120px;
|
|
||||||
z-index: 10;
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<button class="menu-item" @click="handleLogout" style="width: 100%; text-align: left">
|
|
||||||
Log out
|
|
||||||
</button>
|
|
||||||
<button class="menu-item danger" @click="signOut" style="width: 100%; text-align: left">
|
|
||||||
Sign out
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="show" class="modal-backdrop" @click.self="close">
|
|
||||||
<div class="modal">
|
|
||||||
<h3>Enter parent PIN</h3>
|
|
||||||
<form @submit.prevent="submit">
|
|
||||||
<input
|
|
||||||
ref="pinInput"
|
|
||||||
v-model="pin"
|
|
||||||
inputmode="numeric"
|
|
||||||
pattern="\d*"
|
|
||||||
maxlength="6"
|
|
||||||
placeholder="4–6 digits"
|
|
||||||
class="pin-input"
|
|
||||||
/>
|
|
||||||
<div class="actions">
|
|
||||||
<button type="button" class="btn btn-secondary" @click="close">Cancel</button>
|
|
||||||
<button type="submit" class="btn btn-primary">OK</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
<div v-if="error" class="error">{{ error }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
/* modal */
|
|
||||||
|
|
||||||
.modal h3 {
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
font-size: 1.05rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pin-input {
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.5rem 0.6rem;
|
|
||||||
font-size: 1rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid #e6e6e6;
|
|
||||||
margin-bottom: 0.6rem;
|
|
||||||
box-sizing: border-box;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
justify-content: center;
|
|
||||||
margin-bottom: 0.4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-button-root {
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-menu {
|
|
||||||
padding: 0.5rem 0;
|
|
||||||
}
|
|
||||||
.menu-item {
|
|
||||||
padding: 1rem 0.9rem;
|
|
||||||
background: transparent;
|
|
||||||
border: 0;
|
|
||||||
text-align: left;
|
|
||||||
cursor: pointer;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--menu-item-color, #333);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
.menu-item + .menu-item {
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
}
|
|
||||||
.menu-item:hover {
|
|
||||||
background: var(--menu-item-hover-bg, rgba(0, 0, 0, 0.04));
|
|
||||||
}
|
|
||||||
.menu-item.danger {
|
|
||||||
color: var(--menu-item-danger, #ff4d4f);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -147,11 +147,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import ErrorMessage from '@/ErrorMessage.vue'
|
import ErrorMessage from '@/components/shared/ErrorMessage.vue'
|
||||||
import ModalDialog from '@/ModalDialog.vue'
|
import ModalDialog from '@/components/shared/ModalDialog.vue'
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { parseErrorResponse, isEmailValid, isPasswordStrong } from '@/common/api'
|
import { isEmailValid, isPasswordStrong } from '@/common/api'
|
||||||
import { EMAIL_EXISTS, MISSING_FIELDS } from '@/common/errorCodes'
|
import { EMAIL_EXISTS, MISSING_FIELDS } from '@/common/errorCodes'
|
||||||
import '@/assets/view-shared.css'
|
import '@/assets/view-shared.css'
|
||||||
import '@/assets/actions-shared.css'
|
import '@/assets/actions-shared.css'
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, computed, nextTick } from 'vue'
|
import { ref, onMounted, computed, nextTick } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import ImagePicker from '../ImagePicker.vue'
|
import ImagePicker from '@/components/utils/ImagePicker.vue'
|
||||||
import '@/assets/edit-forms.css'
|
import '@/assets/edit-forms.css'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|||||||
@@ -40,7 +40,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, computed, nextTick } from 'vue'
|
import { ref, onMounted, computed, nextTick } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import ImagePicker from '../ImagePicker.vue'
|
import ImagePicker from '@/components/utils/ImagePicker.vue'
|
||||||
import '@/assets/edit-forms.css'
|
import '@/assets/edit-forms.css'
|
||||||
|
|
||||||
const props = defineProps<{ id?: string }>()
|
const props = defineProps<{ id?: string }>()
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, computed, defineEmits, nextTick } from 'vue'
|
import { ref, onMounted, computed, defineEmits, nextTick } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import ImagePicker from '@/components/ImagePicker.vue'
|
import ImagePicker from '@/components/utils/ImagePicker.vue'
|
||||||
import '@/assets/edit-forms.css'
|
import '@/assets/edit-forms.css'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import LoginButton from '../components/LoginButton.vue'
|
import LoginButton from '../components/shared/LoginButton.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import { computed, ref, onMounted } from 'vue'
|
import { computed, ref, onMounted } from 'vue'
|
||||||
import LoginButton from '../components/LoginButton.vue'
|
import LoginButton from '../components/shared/LoginButton.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { watch } from 'vue'
|
|||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
import ChildLayout from '../layout/ChildLayout.vue'
|
import ChildLayout from '../layout/ChildLayout.vue'
|
||||||
import ParentLayout from '../layout/ParentLayout.vue'
|
import ParentLayout from '../layout/ParentLayout.vue'
|
||||||
import ChildrenListView from '../components/ChildrenListView.vue'
|
import ChildrenListView from '../components/shared/ChildrenListView.vue'
|
||||||
import ChildView from '../components/child/ChildView.vue'
|
import ChildView from '../components/child/ChildView.vue'
|
||||||
import ParentView from '../components/child/ParentView.vue'
|
import ParentView from '../components/child/ParentView.vue'
|
||||||
import TaskView from '../components/task/TaskView.vue'
|
import TaskView from '../components/task/TaskView.vue'
|
||||||
|
|||||||
Reference in New Issue
Block a user