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

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

View File

@@ -0,0 +1,18 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useBackendEvents } from '@/common/backendEvents'
import { currentUserId } from '@/stores/auth'
const userId = ref(currentUserId.value)
watch(currentUserId, (id) => {
userId.value = id
})
// Always call useBackendEvents in setup, passing the reactive userId
useBackendEvents(userId)
</script>
<template>
<div></div>
</template>

View File

@@ -0,0 +1,51 @@
<template>
<div class="auth-landing">
<div class="auth-card">
<h1>Welcome</h1>
<p>Please sign in or create an account to continue.</p>
<div class="auth-actions">
<button class="btn btn-primary" @click="goToLogin">Log In</button>
<button class="btn btn-secondary" @click="goToSignup">Sign Up</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
const router = useRouter()
function goToLogin() {
router.push({ name: 'Login' })
}
function goToSignup() {
router.push({ name: 'Signup' })
}
</script>
<style scoped>
.auth-landing {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--header-bg, linear-gradient(135deg, var(--primary), var(--secondary)));
}
.auth-card {
background: var(--card-bg, #fff);
padding: 2.5rem 2rem;
border-radius: 14px;
box-shadow: var(--card-shadow, 0 8px 32px rgba(0, 0, 0, 0.13));
text-align: center;
max-width: 340px;
width: 100%;
}
.auth-card h1 {
color: var(--card-title, #333);
}
.auth-actions {
display: flex;
gap: 1.2rem;
justify-content: center;
margin-top: 2rem;
}
</style>

View File

@@ -0,0 +1,187 @@
<template>
<div class="layout">
<div class="edit-view">
<form class="forgot-form" @submit.prevent="submitForm" novalidate>
<h2>Reset your password</h2>
<div class="form-group">
<label for="email">Email address</label>
<input
id="email"
type="email"
autocomplete="username"
autofocus
v-model="email"
:class="{ 'input-error': submitAttempted && !isEmailValid }"
required
/>
<small v-if="submitAttempted && !email" class="error-message" aria-live="polite">
Email is required.
</small>
<small
v-else-if="submitAttempted && !isEmailValid"
class="error-message"
aria-live="polite"
>
Please enter a valid email address.
</small>
</div>
<div v-if="errorMsg" class="error-message" style="margin-bottom: 1rem" aria-live="polite">
{{ errorMsg }}
</div>
<div
v-if="successMsg"
class="success-message"
style="margin-bottom: 1rem"
aria-live="polite"
>
{{ successMsg }}
</div>
<div class="form-group" style="margin-top: 0.4rem">
<button type="submit" class="form-btn" :disabled="loading || !isEmailValid">
{{ loading ? 'Sending…' : 'Send reset link' }}
</button>
</div>
<p
style="
text-align: center;
margin-top: 0.8rem;
color: var(--sub-message-color, #6b7280);
font-size: 0.95rem;
"
>
Remembered your password?
<button
type="button"
class="btn-link"
@click="goToLogin"
style="
background: none;
border: none;
color: var(--btn-primary);
font-weight: 600;
cursor: pointer;
padding: 0;
margin-left: 6px;
"
>
Sign in
</button>
</p>
</form>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { isEmailValid } from '@/common/api'
import '@/assets/view-shared.css'
import '@/assets/global.css'
import '@/assets/edit-forms.css'
import '@/assets/actions-shared.css'
import '@/assets/button-shared.css'
const router = useRouter()
const email = ref('')
const submitAttempted = ref(false)
const loading = ref(false)
const errorMsg = ref('')
const successMsg = ref('')
const isEmailValidRef = computed(() => isEmailValid(email.value))
async function submitForm() {
submitAttempted.value = true
errorMsg.value = ''
successMsg.value = ''
if (!isEmailValidRef.value) return
loading.value = true
try {
const res = await fetch('/api/request-password-reset', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email.value.trim() }),
})
if (!res.ok) {
let msg = 'Could not send reset email.'
try {
const data = await res.json()
if (data && (data.error || data.message)) {
msg = data.error || data.message
}
} catch {
try {
const text = await res.text()
if (text) msg = text
} catch {}
}
errorMsg.value = msg
return
}
successMsg.value =
'If this email is registered, you will receive a password reset link shortly.'
email.value = ''
submitAttempted.value = false
} catch {
errorMsg.value = 'Network error. Please try again.'
} finally {
loading.value = false
}
}
async function goToLogin() {
await router.push({ name: 'Login' }).catch(() => (window.location.href = '/auth/login'))
}
</script>
<style scoped>
:deep(.edit-view) {
width: 400px;
}
.forgot-form {
background: transparent;
box-shadow: none;
padding: 0;
border: none;
}
.form-group label {
display: block;
margin-bottom: 0.45rem;
color: var(--form-label, #444);
font-weight: 600;
}
.form-group input,
.form-group input[type='email'] {
display: block;
width: 100%;
margin-top: 0.4rem;
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;
}
.btn-link:disabled {
text-decoration: none;
cursor: default;
opacity: 0.75;
}
@media (max-width: 520px) {
.forgot-form {
padding: 1rem;
border-radius: 10px;
}
}
</style>

View File

@@ -0,0 +1,326 @@
<template>
<div class="layout">
<div class="edit-view">
<form class="login-form" @submit.prevent="submitForm" novalidate>
<h2>Sign in</h2>
<div class="form-group">
<label for="email">Email address</label>
<input
id="email"
type="email"
autocomplete="username"
autofocus
v-model="email"
:class="{ 'input-error': submitAttempted && !isEmailValid }"
required
/>
<small v-if="submitAttempted && !email" class="error-message" aria-live="polite"
>Email is required.</small
>
<small
v-else-if="submitAttempted && !isEmailValid"
class="error-message"
aria-live="polite"
>Please enter a valid email address.</small
>
</div>
<div class="form-group">
<label for="password">Password</label>
<input
id="password"
type="password"
autocomplete="current-password"
v-model="password"
:class="{ 'input-error': submitAttempted && !password }"
required
/>
<small v-if="submitAttempted && !password" class="error-message" aria-live="polite"
>Password is required.</small
>
</div>
<!-- show server error message -->
<div v-if="loginError" class="error-message" style="margin-bottom: 1rem" aria-live="polite">
{{ loginError }}
</div>
<!-- show resend UI when server indicated unverified account (independent of loginError) -->
<div v-if="showResend && !resendSent" style="margin-top: 0.5rem">
<button
v-if="!resendLoading"
type="button"
class="btn-link"
@click="resendVerification"
:disabled="!email"
>
Resend verification email
</button>
<span v-else class="btn-link btn-disabled" aria-busy="true">Sending</span>
</div>
<!-- success / error messages for the resend action (shown even if loginError was cleared) -->
<div
v-if="resendSent"
style="margin-top: 0.5rem; color: var(--success, #16a34a); font-size: 0.92rem"
>
Verification email sent. Check your inbox.
</div>
<div v-if="resendError" class="error-message" style="margin-top: 0.5rem" aria-live="polite">
{{ resendError }}
</div>
<div class="form-group" style="margin-top: 0.4rem">
<button type="submit" class="form-btn" :disabled="loading || !formValid">
{{ loading ? 'Signing in…' : 'Sign in' }}
</button>
</div>
<p
style="
text-align: center;
margin-top: 0.8rem;
color: var(--sub-message-color, #6b7280);
font-size: 0.95rem;
"
>
Don't have an account?
<button
type="button"
class="btn-link"
@click="goToSignup"
style="
background: none;
border: none;
color: var(--btn-primary);
font-weight: 600;
cursor: pointer;
padding: 0;
margin-left: 6px;
"
>
Sign up
</button>
</p>
<p
style="
text-align: center;
margin-top: 0.4rem;
color: var(--sub-message-color, #6b7280);
font-size: 0.95rem;
"
>
Forgot your password?
<button
type="button"
class="btn-link"
@click="goToForgotPassword"
style="
background: none;
border: none;
color: var(--btn-primary);
font-weight: 600;
cursor: pointer;
padding: 0;
margin-left: 6px;
"
>
Reset password
</button>
</p>
</form>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import '@/assets/view-shared.css'
import '@/assets/global.css'
import '@/assets/edit-forms.css'
import '@/assets/actions-shared.css'
import '@/assets/button-shared.css'
import {
MISSING_EMAIL_OR_PASSWORD,
INVALID_CREDENTIALS,
NOT_VERIFIED,
MISSING_EMAIL,
USER_NOT_FOUND,
ALREADY_VERIFIED,
} from '@/common/errorCodes'
import { parseErrorResponse, isEmailValid } from '@/common/api'
import { loginUser } from '@/stores/auth'
const router = useRouter()
const email = ref('')
const password = ref('')
const submitAttempted = ref(false)
const loading = ref(false)
const loginError = ref('')
/* new state for resend flow */
const showResend = ref(false)
const resendLoading = ref(false)
const resendSent = ref(false)
const resendError = ref('')
const isEmailValidRef = computed(() => isEmailValid(email.value))
const formValid = computed(() => email.value && isEmailValidRef.value && password.value)
async function submitForm() {
submitAttempted.value = true
loginError.value = ''
showResend.value = false
resendError.value = ''
resendSent.value = false
if (!formValid.value) return
if (loading.value) return
loading.value = true
try {
const res = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email.value.trim(), password: password.value }),
})
if (!res.ok) {
const { msg, code } = await parseErrorResponse(res)
showResend.value = false
let displayMsg = msg
switch (code) {
case MISSING_EMAIL_OR_PASSWORD:
displayMsg = 'Email and password are required.'
break
case INVALID_CREDENTIALS:
displayMsg = 'The email and password combination is incorrect. Please try again.'
break
case NOT_VERIFIED:
displayMsg =
'Your account is not verified. Please check your email for the verification link.'
showResend.value = true
break
default:
displayMsg = msg || `Login failed with status ${res.status}.`
}
loginError.value = displayMsg
return
}
loginUser() // <-- set user as logged in
await router.push({ path: '/' }).catch(() => (window.location.href = '/'))
} catch (err) {
loginError.value = 'Network error. Please try again.'
} finally {
loading.value = false
}
}
async function resendVerification() {
loginError.value = ''
resendError.value = ''
resendSent.value = false
if (!email.value) {
resendError.value = 'Please enter your email above to resend verification.'
return
}
resendLoading.value = true
try {
const res = await fetch('/api/resend-verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email.value }),
})
if (!res.ok) {
const { msg, code } = await parseErrorResponse(res)
resendError.value = msg
let displayMsg = msg
switch (code) {
case MISSING_EMAIL:
displayMsg = 'Email is required.'
break
case USER_NOT_FOUND:
displayMsg = 'This email is not registered.'
break
case ALREADY_VERIFIED:
displayMsg = 'Your account is already verified. Please log in.'
showResend.value = false
break
default:
displayMsg = msg || `Login failed with status ${res.status}.`
}
resendError.value = displayMsg
return
}
resendSent.value = true
} catch {
resendError.value = 'Network error. Please try again.'
} finally {
resendLoading.value = false
}
}
async function goToSignup() {
await router.push({ name: 'Signup' }).catch(() => (window.location.href = '/auth/signup'))
}
async function goToForgotPassword() {
await router
.push({ name: 'ForgotPassword' })
.catch(() => (window.location.href = '/auth/forgot-password'))
}
</script>
<style scoped>
:deep(.edit-view) {
width: 400px;
}
.login-form {
background: transparent;
box-shadow: none;
padding: 0;
border: none;
}
/* reuse edit-forms form-group styles */
.form-group label {
display: block;
margin-bottom: 0.45rem;
color: var(--form-label, #444);
font-weight: 600;
}
.form-group input,
.form-group input[type='email'],
.form-group input[type='password'] {
display: block;
width: 100%;
margin-top: 0.4rem;
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;
}
/* also ensure disabled button doesn't show underline in browsers that style disabled anchors/buttons */
.btn-link:disabled {
text-decoration: none;
cursor: default;
opacity: 0.75;
}
@media (max-width: 520px) {
.login-form {
padding: 1rem;
border-radius: 10px;
}
}
</style>

View File

@@ -0,0 +1,286 @@
<template>
<div class="layout">
<div class="edit-view">
<form
v-if="tokenChecked && tokenValid"
class="reset-form"
@submit.prevent="submitForm"
novalidate
>
<h2>Set a new password</h2>
<div class="form-group">
<label for="password">New password</label>
<input
id="password"
type="password"
autocomplete="new-password"
v-model="password"
:class="{ 'input-error': submitAttempted && !isPasswordStrong }"
required
/>
<small v-if="submitAttempted && !password" class="error-message" aria-live="polite">
Password is required.
</small>
<small
v-else-if="submitAttempted && !isPasswordStrong"
class="error-message"
aria-live="polite"
>
Password must be at least 8 characters and contain a letter and a number.
</small>
</div>
<div class="form-group">
<label for="confirm">Confirm password</label>
<input
id="confirm"
type="password"
autocomplete="new-password"
v-model="confirmPassword"
:class="{ 'input-error': submitAttempted && !passwordsMatch }"
required
/>
<small
v-if="submitAttempted && !confirmPassword"
class="error-message"
aria-live="polite"
>
Please confirm your password.
</small>
<small
v-else-if="submitAttempted && !passwordsMatch"
class="error-message"
aria-live="polite"
>
Passwords do not match.
</small>
</div>
<div v-if="errorMsg" class="error-message" style="margin-bottom: 1rem" aria-live="polite">
{{ errorMsg }}
</div>
<div
v-if="successMsg"
class="success-message"
style="margin-bottom: 1rem"
aria-live="polite"
>
{{ successMsg }}
</div>
<div class="form-group" style="margin-top: 0.4rem">
<button type="submit" class="form-btn" :disabled="loading || !formValid">
{{ loading ? 'Resetting…' : 'Reset password' }}
</button>
</div>
<p
style="
text-align: center;
margin-top: 0.8rem;
color: var(--sub-message-color, #6b7280);
font-size: 0.95rem;
"
>
Remembered your password?
<button
type="button"
class="btn-link"
@click="goToLogin"
style="
background: none;
border: none;
color: var(--btn-primary);
font-weight: 600;
cursor: pointer;
padding: 0;
margin-left: 6px;
"
>
Sign in
</button>
</p>
</form>
<div
v-else-if="tokenChecked && !tokenValid"
class="error-message"
aria-live="polite"
style="margin-top: 2rem"
>
{{ errorMsg }}
<div style="margin-top: 1.2rem">
<button
type="button"
class="btn-link"
@click="goToLogin"
style="
background: none;
border: none;
color: var(--btn-primary);
font-weight: 600;
cursor: pointer;
padding: 0;
margin-left: 6px;
"
>
Sign in
</button>
</div>
</div>
<div v-else style="margin-top: 2rem; text-align: center">Checking reset link...</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { isPasswordStrong } from '@/common/api'
import '@/assets/view-shared.css'
import '@/assets/global.css'
import '@/assets/edit-forms.css'
import '@/assets/actions-shared.css'
import '@/assets/button-shared.css'
const router = useRouter()
const route = useRoute()
const password = ref('')
const confirmPassword = ref('')
const submitAttempted = ref(false)
const loading = ref(false)
const errorMsg = ref('')
const successMsg = ref('')
const token = ref('')
const tokenValid = ref(false)
const tokenChecked = ref(false)
const isPasswordStrongRef = computed(() => isPasswordStrong(password.value))
const passwordsMatch = computed(() => password.value === confirmPassword.value)
const formValid = computed(
() =>
password.value && confirmPassword.value && isPasswordStrongRef.value && passwordsMatch.value,
)
onMounted(async () => {
// Get token from query string
const raw = route.query.token ?? ''
token.value = Array.isArray(raw) ? raw[0] : String(raw || '')
// Validate token with backend
if (token.value) {
try {
const res = await fetch(`/api/validate-reset-token?token=${encodeURIComponent(token.value)}`)
tokenChecked.value = true
if (res.ok) {
tokenValid.value = true
} else {
const data = await res.json().catch(() => ({}))
errorMsg.value = data.error || 'This password reset link is invalid or has expired.'
tokenValid.value = false
}
} catch {
errorMsg.value = 'Network error. Please try again.'
tokenValid.value = false
tokenChecked.value = true
}
} else {
errorMsg.value = 'No reset token provided.'
tokenValid.value = false
tokenChecked.value = true
}
})
async function submitForm() {
submitAttempted.value = true
errorMsg.value = ''
successMsg.value = ''
if (!formValid.value) return
loading.value = true
try {
const res = await fetch('/api/reset-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token: token.value,
password: password.value,
}),
})
if (!res.ok) {
let msg = 'Could not reset password.'
try {
const data = await res.json()
if (data && (data.error || data.message)) {
msg = data.error || data.message
}
} catch {
try {
const text = await res.text()
if (text) msg = text
} catch {}
}
errorMsg.value = msg
return
}
successMsg.value = 'Your password has been reset. You may now sign in.'
password.value = ''
confirmPassword.value = ''
submitAttempted.value = false // <-- add this line
} catch {
errorMsg.value = 'Network error. Please try again.'
} finally {
loading.value = false
}
}
async function goToLogin() {
await router.push({ name: 'Login' }).catch(() => (window.location.href = '/auth/login'))
}
</script>
<style scoped>
:deep(.edit-view) {
width: 400px;
}
.reset-form {
background: transparent;
box-shadow: none;
padding: 0;
border: none;
}
.form-group label {
display: block;
margin-bottom: 0.45rem;
color: var(--form-label, #444);
font-weight: 600;
}
.form-group input,
.form-group input[type='password'] {
display: block;
width: 100%;
margin-top: 0.4rem;
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;
}
.btn-link:disabled {
text-decoration: none;
cursor: default;
opacity: 0.75;
}
@media (max-width: 520px) {
.reset-form {
padding: 1rem;
border-radius: 10px;
}
}
</style>

View File

@@ -0,0 +1,369 @@
<template>
<div class="layout">
<div class="edit-view">
<form
v-if="!signupSuccess"
@submit.prevent="submitForm"
class="signup-form child-edit-view"
novalidate
>
<h2>Sign up</h2>
<div class="form-group">
<label for="firstName">First name</label>
<input
v-model="firstName"
id="firstName"
type="text"
autofocus
autocomplete="given-name"
required
:class="{ 'input-error': submitAttempted && !firstName }"
/>
<small v-if="submitAttempted && !firstName" class="error-message" aria-live="polite"
>First name is required.</small
>
</div>
<div class="form-group">
<label for="lastName">Last name</label>
<input
v-model="lastName"
id="lastName"
autocomplete="family-name"
type="text"
required
:class="{ 'input-error': submitAttempted && !lastName }"
/>
<small v-if="submitAttempted && !lastName" class="error-message" aria-live="polite"
>Last name is required.</small
>
</div>
<div class="form-group">
<label for="email">Email address</label>
<input
v-model="email"
id="email"
autocomplete="email"
type="email"
required
:class="{ 'input-error': submitAttempted && (!email || !isEmailValid) }"
/>
<small v-if="submitAttempted && !email" class="error-message" aria-live="polite"
>Email is required.</small
>
<small v-else-if="submitAttempted && !isEmailValid" class="error-message"
>Please enter a valid email address.</small
>
</div>
<div class="form-group">
<label for="password">Password</label>
<input
v-model="password"
id="password"
autocomplete="new-password"
type="password"
required
@input="checkPasswordStrength"
:class="{ 'input-error': (submitAttempted || passwordTouched) && !isPasswordStrong }"
/>
<small
v-if="(submitAttempted || passwordTouched) && !isPasswordStrong"
class="error-message"
aria-live="polite"
>Password must be at least 8 characters, include a number and a letter.</small
>
</div>
<div class="form-group">
<label for="confirmPassword">Confirm password</label>
<input
v-model="confirmPassword"
id="confirmPassword"
autocomplete="new-password"
type="password"
required
:class="{ 'input-error': (submitAttempted || confirmTouched) && !passwordsMatch }"
@blur="confirmTouched = true"
/>
<small
v-if="(submitAttempted || confirmTouched) && !passwordsMatch"
class="error-message"
aria-live="polite"
>Passwords do not match.</small
>
</div>
<div class="form-group" style="margin-top: 0.4rem">
<button type="submit" class="form-btn" :disabled="!formValid || loading">Sign up</button>
</div>
</form>
<!-- Error and success messages -->
<ErrorMessage v-if="signupError" :message="signupError" aria-live="polite" />
<!-- Modal for "Account already exists" -->
<ModalDialog v-if="showEmailExistsModal">
<h3>Account already exists</h3>
<p>
An account with <strong>{{ email }}</strong> already exists.
</p>
<div style="display: flex; gap: 2rem; justify-content: center">
<button @click="goToLogin" class="form-btn">Sign In</button>
<button @click="showEmailExistsModal = false" class="form-btn">Cancel</button>
</div>
</ModalDialog>
<!-- Verification card shown after successful signup -->
<div v-else-if="signupSuccess">
<div class="icon-wrap" aria-hidden="true">
<!-- simple check icon -->
<svg
class="success-icon"
width="48"
height="48"
viewBox="0 0 24 24"
fill="none"
stroke="white"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M20 6L9 17l-5-5" />
</svg>
</div>
<h2 class="card-title">Check your email</h2>
<p class="card-message">
A verification link has been sent to <strong>{{ email }}</strong
>. Please open the email and follow the instructions to verify your account.
</p>
<div class="card-actions">
<button class="form-btn" @click="goToLogin">Go to Sign In</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import ErrorMessage from '@/components/shared/ErrorMessage.vue'
import ModalDialog from '@/components/shared/ModalDialog.vue'
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { isEmailValid, isPasswordStrong } from '@/common/api'
import { EMAIL_EXISTS, MISSING_FIELDS } from '@/common/errorCodes'
import '@/assets/view-shared.css'
import '@/assets/actions-shared.css'
import '@/assets/button-shared.css'
import '@/assets/global.css'
import '@/assets/edit-forms.css'
const router = useRouter()
const firstName = ref('')
const lastName = ref('')
const email = ref('')
const password = ref('')
const confirmPassword = ref('')
const passwordTouched = ref(false)
const confirmTouched = ref(false)
const submitAttempted = ref(false)
const signupError = ref('')
const signupSuccess = ref(false)
const showEmailExistsModal = ref(false)
const loading = ref(false)
function checkPasswordStrength() {
passwordTouched.value = true
}
const isEmailValidRef = computed(() => isEmailValid(email.value))
const isPasswordStrongRef = computed(() => isPasswordStrong(password.value))
const passwordsMatch = computed(() => password.value === confirmPassword.value)
const formValid = computed(
() =>
firstName.value &&
lastName.value &&
email.value &&
isEmailValidRef.value &&
isPasswordStrongRef.value &&
passwordsMatch.value,
)
async function submitForm() {
submitAttempted.value = true
passwordTouched.value = true
confirmTouched.value = true
signupError.value = ''
signupSuccess.value = false
showEmailExistsModal.value = false
if (!formValid.value) return
try {
loading.value = true
const response = await fetch('/api/signup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
first_name: firstName.value.trim(),
last_name: lastName.value.trim(),
email: email.value.trim(),
password: password.value,
}),
})
if (!response.ok) {
const { msg, code } = await parseErrorResponse(response)
let displayMsg = msg
switch (code) {
case MISSING_FIELDS:
displayMsg = 'Please fill in all required fields.'
clearFields()
break
case EMAIL_EXISTS:
displayMsg = 'An account with this email already exists.'
showEmailExistsModal.value = true
break
default:
break
}
signupError.value = displayMsg
return
}
// Signup successful
signupSuccess.value = true
clearFields()
} catch (err) {
signupError.value = 'Network error. Please try again.'
} finally {
loading.value = false
}
}
function goToLogin() {
router.push({ name: 'Login' }).catch(() => (window.location.href = '/auth/login'))
}
// Clear password fields and close modal
function handleCancelEmailExists() {
password.value = ''
confirmPassword.value = ''
showEmailExistsModal.value = false
}
async function parseErrorResponse(res: Response): Promise<{ msg: string; code?: string }> {
try {
const data = await res.json()
return { msg: data.error || data.message || 'Signup failed.', code: data.code }
} catch {
const text = await res.text()
return { msg: text || 'Signup failed.' }
}
}
function clearFields() {
firstName.value = ''
lastName.value = ''
email.value = ''
password.value = ''
confirmPassword.value = ''
passwordTouched.value = false
confirmTouched.value = false
submitAttempted.value = false
signupError.value = ''
}
</script>
<style scoped>
:deep(.edit-view) {
width: 400px;
}
.signup-form {
/* keep the edit-view / child-edit-view look from edit-forms.css,
only adjust inputs for email/password types */
background: transparent;
box-shadow: none;
padding: 0;
border: none;
}
.icon-wrap {
width: 72px;
height: 72px;
border-radius: 50%;
margin: 0 auto 1rem;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--btn-green, #22c55e), var(--btn-green-hover, #16a34a));
box-shadow: 0 8px 20px rgba(34, 197, 94, 0.12);
}
.success-icon {
width: 36px;
height: 36px;
stroke: #fff;
}
.card-title {
margin: 0;
font-size: 1.25rem;
font-weight: 700;
color: var(--card-title, #333);
}
.card-message {
margin-top: 0.6rem;
color: var(--sub-message-color, #6b7280);
font-size: 0.98rem;
line-height: 1.4;
}
.card-actions {
margin-top: 1.2rem;
display: flex;
gap: 0.6rem;
justify-content: center;
}
/* Reuse existing input / label styles */
.form-group label {
display: block;
margin-bottom: 0.45rem;
color: var(--form-label, #444);
font-weight: 600;
}
.form-group input,
.form-group input[type='email'],
.form-group input[type='password'] {
display: block;
width: 100%;
margin-top: 0.4rem;
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;
}
/* Modal styles */
.modal-backdrop {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.35);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-dialog {
background: #fff;
padding: 2rem;
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
max-width: 340px;
text-align: center;
}
</style>

View File

@@ -0,0 +1,316 @@
<template>
<div class="layout">
<div class="edit-view">
<div class="verify-container">
<h2 v-if="verifyingLoading">Verifying</h2>
<div v-if="verified" class="success-message" aria-live="polite">
Your account has been verified.
<div class="meta">
Redirecting to sign in in <strong>{{ countdown }}</strong> second<span
v-if="countdown !== 1"
>s</span
>.
</div>
<div style="margin-top: 0.6rem">
<button
type="button"
class="btn-link"
@click="goToLogin"
style="
background: none;
border: none;
color: var(--btn-primary);
font-weight: 600;
cursor: pointer;
padding: 0;
margin-left: 6px;
"
>
Sign in
</button>
</div>
</div>
<div v-else>
<!-- Error or success message at the top -->
<div
v-if="verifyError"
class="error-message"
aria-live="polite"
style="margin-bottom: 1rem"
>
{{ verifyError }}
</div>
<div
v-if="resendSuccess"
class="success-message"
aria-live="polite"
style="margin-bottom: 1rem"
>
Verification email sent. Check your inbox.
</div>
<!-- Email form and resend button -->
<form @submit.prevent="handleResend" v-if="!sendingDialog">
<label
for="resend-email"
style="display: block; font-weight: 600; margin-bottom: 0.25rem"
>Email address</label
>
<input
id="resend-email"
v-model.trim="resendEmail"
autofocus
type="email"
placeholder="you@example.com"
:class="{ 'input-error': resendAttempted && !isResendEmailValid }"
/>
<small v-if="resendAttempted && !resendEmail" class="error-message" aria-live="polite"
>Email is required.</small
>
<small
v-else-if="resendAttempted && !isResendEmailValid"
class="error-message"
aria-live="polite"
>Please enter a valid email address.</small
>
<div style="margin-top: 0.6rem">
<button
type="submit"
class="form-btn"
:disabled="!isResendEmailValid || resendLoading"
>
Resend verification email
</button>
</div>
</form>
<!-- Sending dialog -->
<div v-if="sendingDialog" class="sending-dialog">
<div
class="modal-backdrop"
style="
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;
"
>
<div
class="modal-dialog"
style="
background: #fff;
padding: 2rem;
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
max-width: 340px;
text-align: center;
"
>
<h3 style="margin-bottom: 1rem">Sending Verification Email</h3>
</div>
</div>
</div>
<div style="margin-top: 0.8rem">
<button
type="button"
class="btn-link"
@click="goToLogin"
style="
background: none;
border: none;
color: var(--btn-primary);
font-weight: 600;
cursor: pointer;
padding: 0;
margin-left: 6px;
"
>
Sign in
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import {
MISSING_TOKEN,
TOKEN_TIMESTAMP_MISSING,
TOKEN_EXPIRED,
INVALID_TOKEN,
MISSING_EMAIL,
USER_NOT_FOUND,
ALREADY_VERIFIED,
} from '@/common/errorCodes'
import '@/assets/actions-shared.css'
import '@/assets/button-shared.css'
import { parseErrorResponse } from '@/common/api'
const router = useRouter()
const route = useRoute()
const verifyingLoading = ref(true)
const verified = ref(false)
const verifyError = ref('')
const resendSuccess = ref(false)
const countdown = ref(10)
let countdownTimer: number | null = null
// Resend state
const resendEmail = ref<string>((route.query.email as string) ?? '')
const resendAttempted = ref(false)
const resendLoading = ref(false)
const sendingDialog = ref(false)
const isResendEmailValid = computed(() => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(resendEmail.value))
async function verifyToken() {
const raw = route.query.token ?? ''
const token = Array.isArray(raw) ? raw[0] : String(raw || '')
if (!token) {
router.push({ name: 'Login' }).catch(() => (window.location.href = '/auth/login'))
return
}
verifyingLoading.value = true
try {
const url = `/api/verify?token=${encodeURIComponent(token)}`
const res = await fetch(url, { method: 'GET' })
if (!res.ok) {
const { msg, code } = await parseErrorResponse(res)
switch (code) {
case INVALID_TOKEN:
case MISSING_TOKEN:
case TOKEN_TIMESTAMP_MISSING:
verifyError.value =
"Your account isn't verified. Please request a new verification email."
break
case TOKEN_EXPIRED:
verifyError.value =
'Your verification link has expired. Please request a new verification email.'
break
default:
verifyError.value = msg || `Verification failed with status ${res.status}.`
}
return
}
// success
verified.value = true
startRedirectCountdown()
} catch {
verifyError.value = 'Network error. Please try again.'
} finally {
verifyingLoading.value = false
}
}
function startRedirectCountdown() {
countdown.value = 10
countdownTimer = window.setInterval(() => {
countdown.value -= 1
if (countdown.value <= 0) {
clearCountdown()
router.push({ name: 'Login' }).catch(() => (window.location.href = '/auth/login'))
}
}, 1000)
}
function clearCountdown() {
if (countdownTimer !== null) {
clearInterval(countdownTimer)
countdownTimer = null
}
}
onBeforeUnmount(() => {
clearCountdown()
})
onMounted(() => {
verifyToken()
})
async function handleResend() {
resendAttempted.value = true
resendSuccess.value = false
verifyError.value = ''
if (!isResendEmailValid.value) return
sendingDialog.value = true
resendLoading.value = true
try {
const res = await fetch('/api/resend-verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: resendEmail.value.trim() }),
})
resendEmail.value = ''
sendingDialog.value = false
resendLoading.value = false
if (!res.ok) {
const { msg, code } = await parseErrorResponse(res)
switch (code) {
case MISSING_EMAIL:
verifyError.value = 'An email address is required.'
break
case USER_NOT_FOUND:
verifyError.value = 'This email is not registered.'
break
case ALREADY_VERIFIED:
verifyError.value = 'Your account is already verified. Please log in.'
break
default:
verifyError.value = msg || `Resend failed with status ${res.status}.`
}
return
}
resendSuccess.value = true
verifyError.value = ''
} catch {
verifyError.value = 'Network error. Please try again.'
} finally {
sendingDialog.value = false
resendLoading.value = false
resendAttempted.value = false
}
}
function goToLogin() {
router.push({ name: 'Login' }).catch(() => (window.location.href = '/auth/login'))
}
</script>
<style scoped>
:deep(.edit-view) {
width: 400px;
}
.verify-container {
max-width: 520px;
margin: 0 auto;
padding: 0.6rem;
}
.meta {
margin-top: 0.5rem;
color: var(--sub-message-color, #6b7280);
}
</style>

View File

@@ -0,0 +1,142 @@
<script setup lang="ts">
import { toRefs, ref, watch, onBeforeUnmount } from 'vue'
import { getCachedImageUrl, revokeAllImageUrls } from '../../common/imageCache'
interface Child {
id: string | number
name: string
age: number
points?: number
image_id: string | null
}
const props = defineProps<{
child: Child | null
}>()
const { child } = toRefs(props)
const imageUrl = ref<string | null>(null)
const imageCacheName = 'images-v1'
const fetchImage = async (imageId: string) => {
try {
const url = await getCachedImageUrl(imageId, imageCacheName)
imageUrl.value = url
} catch (err) {
console.error('Error fetching child image:', err)
}
}
watch(
() => child.value?.image_id,
(newImageId) => {
if (newImageId) {
fetchImage(newImageId)
}
},
{ immediate: true },
)
// Revoke created object URLs when component unmounts to avoid memory leaks
onBeforeUnmount(() => {
revokeAllImageUrls()
})
</script>
<template>
<div v-if="child" class="detail-card-horizontal">
<img v-if="imageUrl" :src="imageUrl" alt="Child Image" class="child-image" />
<div class="main-info">
<div class="child-name">{{ child.name }}</div>
<div class="child-age">Age: {{ child.age }}</div>
</div>
<div class="points">
<span class="label">Points</span>
<span class="value">{{ child.points ?? '—' }}</span>
</div>
</div>
</template>
<style scoped>
.detail-card-horizontal {
display: flex;
align-items: center;
background: var(--detail-card-bg);
border-radius: 12px;
box-shadow: var(--detail-card-shadow);
padding: 0.7rem 1rem;
max-width: 420px;
width: 100%;
min-height: 64px;
box-sizing: border-box;
gap: 1rem;
}
.child-image {
width: 48px;
height: 48px;
object-fit: cover;
border-radius: 50%;
flex-shrink: 0;
}
.main-info {
display: flex;
flex-direction: column;
justify-content: center;
flex: 1 1 auto;
min-width: 0;
}
.child-name {
font-size: 1.08rem;
font-weight: 600;
color: var(--child-name-color);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.child-age {
font-size: 0.97rem;
color: var(--child-age-color);
margin-top: 2px;
}
.points {
display: flex;
flex-direction: column;
align-items: flex-end;
min-width: 54px;
margin-left: 0.7rem;
}
.points .label {
font-size: 0.85rem;
color: var(--points-label-color);
margin-bottom: 1px;
}
.points .value {
font-size: 1.6rem;
font-weight: 900;
color: var(--points-value-color);
}
@media (max-width: 480px) {
.detail-card-horizontal {
padding: 0.5rem 0.4rem;
max-width: 98vw;
gap: 0.6rem;
}
.child-image {
width: 38px;
height: 38px;
}
.points {
min-width: 38px;
margin-left: 0.3rem;
}
}
</style>

View File

@@ -0,0 +1,157 @@
<template>
<div class="child-edit-view">
<h2>{{ isEdit ? 'Edit Child' : 'Create Child' }}</h2>
<div v-if="loading" class="loading-message">Loading child...</div>
<form v-else @submit.prevent="submit" class="child-edit-form">
<div class="group">
<label for="child-name">Name</label>
<input type="text" id="child-name" ref="nameInput" v-model="name" required maxlength="64" />
</div>
<div class="group">
<label for="child-age">Age</label>
<input id="child-age" v-model.number="age" type="number" min="0" max="120" required />
</div>
<div class="group">
<label for="child-image">Image</label>
<ImagePicker
id="child-image"
v-model="selectedImageId"
:image-type="1"
@add-image="onAddImage"
/>
</div>
<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">
{{ isEdit ? 'Save' : 'Create' }}
</button>
</div>
</form>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import ImagePicker from '@/components/utils/ImagePicker.vue'
import '@/assets/edit-forms.css'
const route = useRoute()
const router = useRouter()
// Accept id as a prop for edit mode
const props = defineProps<{ id?: string }>()
const isEdit = computed(() => !!props.id)
const name = ref('')
const age = ref<number | null>(null)
const selectedImageId = ref<string | null>(null)
const localImageFile = ref<File | null>(null)
const nameInput = ref<HTMLInputElement | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
onMounted(async () => {
if (isEdit.value && props.id) {
loading.value = true
try {
const resp = await fetch(`/api/child/${props.id}`)
if (!resp.ok) throw new Error('Failed to load child')
const data = await resp.json()
name.value = data.name ?? ''
age.value = Number(data.age) ?? null
selectedImageId.value = data.image_id ?? null
} catch (e) {
error.value = 'Could not load child.'
} finally {
loading.value = false
await nextTick()
nameInput.value?.focus()
}
} else {
await nextTick()
nameInput.value?.focus()
}
})
function onAddImage({ id, file }: { id: string; file: File }) {
if (id === 'local-upload') {
localImageFile.value = file
}
}
const submit = async () => {
let imageId = selectedImageId.value
error.value = null
if (!name.value.trim()) {
error.value = 'Child name is required.'
return
}
if (age.value === null || age.value < 0) {
error.value = 'Age must be a non-negative number.'
return
}
loading.value = true
// If the selected image is a local upload, upload it first
if (imageId === 'local-upload' && localImageFile.value) {
const formData = new FormData()
formData.append('file', localImageFile.value)
formData.append('type', '1')
formData.append('permanent', 'false')
try {
const resp = await fetch('/api/image/upload', {
method: 'POST',
body: formData,
})
if (!resp.ok) throw new Error('Image upload failed')
const data = await resp.json()
imageId = data.id
} catch (err) {
alert('Failed to upload image.')
loading.value = false
return
}
}
// Now update or create the child
try {
let resp
if (isEdit.value && props.id) {
resp = await fetch(`/api/child/${props.id}/edit`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: name.value,
age: age.value,
image_id: imageId,
}),
})
} else {
resp = await fetch('/api/child/add', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: name.value,
age: age.value,
image_id: imageId,
}),
})
}
if (!resp.ok) throw new Error('Failed to save child')
await router.push({ name: 'ParentChildrenListView' })
} catch (err) {
alert('Failed to save child.')
}
loading.value = false
}
function onCancel() {
router.back()
}
</script>
<style scoped></style>

View File

@@ -0,0 +1,533 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import ChildDetailCard from './ChildDetailCard.vue'
import ScrollingList from '../shared/ScrollingList.vue'
import { eventBus } from '@/common/eventBus'
import '@/assets/view-shared.css'
import type {
Child,
Event,
Task,
Reward,
RewardStatus,
ChildTaskTriggeredEventPayload,
ChildRewardTriggeredEventPayload,
ChildRewardRequestEventPayload,
ChildTasksSetEventPayload,
ChildRewardsSetEventPayload,
TaskModifiedEventPayload,
RewardModifiedEventPayload,
ChildModifiedEventPayload,
} from '@/common/models'
const route = useRoute()
const router = useRouter()
const child = ref<Child | null>(null)
const tasks = ref<string[]>([])
const rewards = ref<string[]>([])
const loading = ref(true)
const error = ref<string | null>(null)
const showRewardDialog = ref(false)
const showCancelDialog = ref(false)
const dialogReward = ref<Reward | null>(null)
const childRewardListRef = ref()
function handleTaskTriggered(event: Event) {
const payload = event.payload as ChildTaskTriggeredEventPayload
if (child.value && payload.child_id == child.value.id) {
child.value.points = payload.points
}
}
function handleRewardTriggered(event: Event) {
const payload = event.payload as ChildRewardTriggeredEventPayload
if (child.value && payload.child_id == child.value.id) {
child.value.points = payload.points
}
}
function handleChildTaskSet(event: Event) {
const payload = event.payload as ChildTasksSetEventPayload
if (child.value && payload.child_id == child.value.id) {
tasks.value = payload.task_ids
}
}
function handleChildRewardSet(event: Event) {
const payload = event.payload as ChildRewardsSetEventPayload
if (child.value && payload.child_id == child.value.id) {
rewards.value = payload.reward_ids
}
}
function handleRewardRequest(event: Event) {
const payload = event.payload as ChildRewardRequestEventPayload
const childId = payload.child_id
const rewardId = payload.reward_id
if (child.value && childId == child.value.id) {
if (rewards.value.find((r) => r === rewardId)) {
childRewardListRef.value?.refresh()
}
}
}
function handleChildModified(event: Event) {
const payload = event.payload as ChildModifiedEventPayload
if (child.value && payload.child_id == child.value.id) {
switch (payload.operation) {
case 'DELETE':
// Navigate away back to children list
router.push({ name: 'ChildrenListView' })
break
case 'ADD':
// A new child was added, this shouldn't affect the current child view
console.log('ADD operation received for child_modified, no action taken.')
break
case 'EDIT':
//our child was edited, refetch its data
try {
const dataPromise = fetchChildData(payload.child_id)
dataPromise.then((data) => {
if (data) {
child.value = data
}
loading.value = false
})
} catch (err) {
console.warn('Failed to fetch child after EDIT operation:', err)
}
break
default:
console.warn(`Unknown operation: ${payload.operation}`)
}
}
}
function handleTaskModified(event: Event) {
const payload = event.payload as TaskModifiedEventPayload
if (child.value) {
const task_id = payload.task_id
if (tasks.value.includes(task_id)) {
try {
switch (payload.operation) {
case 'DELETE':
// Remove the task from the list
tasks.value = tasks.value.filter((t) => t !== task_id)
return // No need to refetch
case 'ADD':
// A new task was added, this shouldn't affect the current task list
console.log('ADD operation received for task_modified, no action taken.')
return // No need to refetch
case 'EDIT':
try {
const dataPromise = fetchChildData(child.value.id)
dataPromise.then((data) => {
if (data) {
tasks.value = data.tasks || []
}
})
} catch (err) {
console.warn('Failed to fetch child after EDIT operation:', err)
} finally {
loading.value = false
}
break
default:
console.warn(`Unknown operation: ${payload.operation}`)
return // No need to refetch
}
} catch (err) {
console.warn('Failed to fetch child after task modification:', err)
}
}
}
}
function handleRewardModified(event: Event) {
const payload = event.payload as RewardModifiedEventPayload
if (child.value) {
const reward_id = payload.reward_id
if (rewards.value.includes(reward_id)) {
childRewardListRef.value?.refresh()
}
}
}
const triggerTask = (task: Task) => {
if ('speechSynthesis' in window && task.name) {
const utter = new window.SpeechSynthesisUtterance(task.name)
window.speechSynthesis.speak(utter)
}
}
const triggerReward = (reward: RewardStatus) => {
if ('speechSynthesis' in window && reward.name) {
const utterString =
reward.name +
(reward.points_needed <= 0 ? '' : `, You still need ${reward.points_needed} points.`)
const utter = new window.SpeechSynthesisUtterance(utterString)
window.speechSynthesis.speak(utter)
console.log('Reward data is:', reward)
if (reward.redeeming) {
dialogReward.value = reward
showCancelDialog.value = true
return // Do not allow redeeming if already pending
}
if (reward.points_needed <= 0) {
dialogReward.value = reward
showRewardDialog.value = true
}
}
}
async function cancelPendingReward() {
if (!child.value?.id || !dialogReward.value) return
try {
const resp = await fetch(`/api/child/${child.value.id}/cancel-request-reward`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reward_id: dialogReward.value.id }),
})
if (!resp.ok) throw new Error('Failed to cancel pending reward')
} catch (err) {
console.error('Failed to cancel pending reward:', err)
} finally {
showCancelDialog.value = false
dialogReward.value = null
}
}
function cancelRedeemReward() {
showRewardDialog.value = false
dialogReward.value = null
}
function closeCancelDialog() {
showCancelDialog.value = false
dialogReward.value = null
}
async function confirmRedeemReward() {
if (!child.value?.id || !dialogReward.value) return
try {
const resp = await fetch(`/api/child/${child.value.id}/request-reward`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reward_id: dialogReward.value.id }),
})
if (!resp.ok) return
} catch (err) {
console.error('Failed to redeem reward:', err)
} finally {
showRewardDialog.value = false
dialogReward.value = null
}
}
async function fetchChildData(id: string | number) {
loading.value = true
try {
const resp = await fetch(`/api/child/${id}`)
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
const data = await resp.json()
error.value = null
return data
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to fetch child'
console.error(err)
return null
} finally {
}
}
let inactivityTimer: ReturnType<typeof setTimeout> | null = null
function resetInactivityTimer() {
if (inactivityTimer) clearTimeout(inactivityTimer)
inactivityTimer = setTimeout(() => {
router.push({ name: 'ChildrenListView' })
}, 60000) // 60 seconds
}
function setupInactivityListeners() {
const events = ['mousemove', 'mousedown', 'keydown', 'touchstart']
events.forEach((evt) => window.addEventListener(evt, resetInactivityTimer))
}
function removeInactivityListeners() {
const events = ['mousemove', 'mousedown', 'keydown', 'touchstart']
events.forEach((evt) => window.removeEventListener(evt, resetInactivityTimer))
if (inactivityTimer) clearTimeout(inactivityTimer)
}
const hasPendingRewards = computed(() =>
childRewardListRef.value?.items.some((r: RewardStatus) => r.redeeming),
)
onMounted(async () => {
try {
eventBus.on('child_task_triggered', handleTaskTriggered)
eventBus.on('child_reward_triggered', handleRewardTriggered)
eventBus.on('child_tasks_set', handleChildTaskSet)
eventBus.on('child_rewards_set', handleChildRewardSet)
eventBus.on('task_modified', handleTaskModified)
eventBus.on('reward_modified', handleRewardModified)
eventBus.on('child_modified', handleChildModified)
eventBus.on('child_reward_request', handleRewardRequest)
if (route.params.id) {
const idParam = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id
if (idParam !== undefined) {
const promise = fetchChildData(idParam)
promise.then((data) => {
if (data) {
child.value = data
tasks.value = data.tasks || []
rewards.value = data.rewards || []
}
loading.value = false
})
}
}
setupInactivityListeners()
resetInactivityTimer()
} catch (err) {
console.error('Error in onMounted:', err)
}
})
onUnmounted(() => {
eventBus.off('child_task_triggered', handleTaskTriggered)
eventBus.off('child_reward_triggered', handleRewardTriggered)
eventBus.off('child_tasks_set', handleChildTaskSet)
eventBus.off('child_rewards_set', handleChildRewardSet)
eventBus.off('task_modified', handleTaskModified)
eventBus.off('reward_modified', handleRewardModified)
eventBus.off('child_modified', handleChildModified)
eventBus.off('child_reward_request', handleRewardRequest)
removeInactivityListeners()
})
</script>
<template>
<div>
<div v-if="loading" class="loading">Loading...</div>
<div v-else-if="error" class="error">Error: {{ error }}</div>
<div v-else class="layout">
<div class="main">
<ChildDetailCard :child="child" />
<ScrollingList
title="Chores"
ref="childChoreListRef"
:fetchBaseUrl="`/api/task/list?ids=${tasks.join(',')}`"
:ids="tasks"
itemKey="tasks"
imageField="image_id"
@trigger-item="triggerTask"
:getItemClass="(item) => ({ bad: !item.is_good, good: item.is_good })"
:filter-fn="
(item) => {
return item.is_good
}
"
>
<template #item="{ item }">
<div class="item-name">{{ item.name }}</div>
<img v-if="item.image_url" :src="item.image_url" alt="Task Image" class="item-image" />
<div
class="item-points"
:class="{ 'good-points': item.is_good, 'bad-points': !item.is_good }"
>
{{ item.is_good ? item.points : -item.points }} Points
</div>
</template>
</ScrollingList>
<ScrollingList
title="Penalties"
ref="childPenaltyListRef"
:fetchBaseUrl="`/api/task/list?ids=${tasks.join(',')}`"
:ids="tasks"
itemKey="tasks"
imageField="image_id"
@trigger-item="triggerTask"
:getItemClass="(item) => ({ bad: !item.is_good, good: item.is_good })"
:filter-fn="
(item) => {
return !item.is_good
}
"
>
<template #item="{ item }">
<div class="item-name">{{ item.name }}</div>
<img v-if="item.image_url" :src="item.image_url" alt="Task Image" class="item-image" />
<div
class="item-points"
:class="{ 'good-points': item.is_good, 'bad-points': !item.is_good }"
>
{{ item.is_good ? item.points : -item.points }} Points
</div>
</template>
</ScrollingList>
<ScrollingList
title="Rewards"
ref="childRewardListRef"
:fetchBaseUrl="`/api/child/${child?.id}/reward-status`"
itemKey="reward_status"
imageField="image_id"
@trigger-item="triggerReward"
:getItemClass="
(item) => ({ reward: true, disabled: hasPendingRewards && !item.redeeming })
"
>
<template #item="{ item }: { item: RewardStatus }">
<div class="item-name">{{ item.name }}</div>
<img
v-if="item.image_url"
:src="item.image_url"
alt="Reward Image"
class="item-image"
/>
<div class="item-points">
<span v-if="item.redeeming" class="pending">PENDING</span>
<span v-if="item.points_needed <= 0" class="ready">REWARD READY</span>
<span v-else>{{ item.points_needed }} more points</span>
</div>
</template>
</ScrollingList>
</div>
</div>
<div v-if="showRewardDialog && dialogReward" class="modal-backdrop">
<div class="modal">
<div class="reward-info">
<img
v-if="dialogReward.image_url"
:src="dialogReward.image_url"
alt="Reward Image"
class="reward-image"
/>
<div class="reward-details">
<div class="reward-name">{{ dialogReward.name }}</div>
<div class="reward-points">{{ dialogReward.cost }} pts</div>
</div>
</div>
<div class="dialog-message" style="margin-bottom: 1.2rem">
Would you like to redeem this reward?
</div>
<div class="actions">
<button @click="confirmRedeemReward" class="btn btn-primary">Yes</button>
<button @click="cancelRedeemReward" class="btn btn-secondary">No</button>
</div>
</div>
</div>
<div v-if="showCancelDialog && dialogReward" class="modal-backdrop">
<div class="modal">
<div class="reward-info">
<img
v-if="dialogReward.image_url"
:src="dialogReward.image_url"
alt="Reward Image"
class="reward-image"
/>
<div class="reward-details">
<div class="reward-name">{{ dialogReward.name }}</div>
<div class="reward-points">{{ dialogReward.cost }} pts</div>
</div>
</div>
<div class="dialog-message" style="margin-bottom: 1.2rem">
This reward is pending.<br />
Would you like to cancel the pending reward request?
</div>
<div class="actions">
<button @click="cancelPendingReward" class="btn btn-primary">Yes</button>
<button @click="closeCancelDialog" class="btn btn-secondary">No</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.assign-buttons {
display: flex;
gap: 1rem;
justify-content: center;
margin: 2rem 0;
}
.back-btn {
background: var(--back-btn-bg);
border: 0;
padding: 0.6rem 1rem;
border-radius: 8px;
cursor: pointer;
margin-bottom: 1.5rem;
color: var(--back-btn-color);
font-weight: 600;
}
.item-points {
color: var(--item-points-color, #ffd166);
font-size: 1rem;
font-weight: 900;
text-shadow: var(--item-points-shadow);
}
.ready {
color: var(--item-points-ready-color, #38c172);
letter-spacing: 0.5px;
}
.pending {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 80%;
background: var(--pending-block-bg, #222b);
color: var(--pending-block-color, #62ff7a);
font-weight: 700;
font-size: 1.05rem;
text-align: center;
border-radius: 6px;
padding: 0.4rem 0;
letter-spacing: 2px;
display: flex;
align-items: center;
justify-content: center;
z-index: 2;
opacity: 0.95;
pointer-events: none;
}
/* Mobile tweaks */
@media (max-width: 480px) {
.item-points {
font-size: 0.78rem;
}
}
:deep(.good) {
border-color: var(--list-item-border-good);
background: var(--list-item-bg-good);
}
:deep(.bad) {
border-color: var(--list-item-border-bad);
background: var(--list-item-bg-bad);
}
:deep(.reward) {
border-color: var(--list-item-border-reward);
background: var(--list-item-bg-reward);
}
:deep(.disabled) {
opacity: 0.5;
pointer-events: none;
filter: grayscale(0.7);
}
</style>

View File

@@ -0,0 +1,600 @@
<script setup lang="ts">
import { ref, onMounted, computed, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import ChildDetailCard from './ChildDetailCard.vue'
import ScrollingList from '../shared/ScrollingList.vue'
import { eventBus } from '@/common/eventBus'
import '@/assets/view-shared.css'
import type {
Task,
Child,
Event,
Reward,
RewardStatus,
ChildTaskTriggeredEventPayload,
ChildRewardTriggeredEventPayload,
ChildRewardRequestEventPayload,
ChildTasksSetEventPayload,
ChildRewardsSetEventPayload,
ChildModifiedEventPayload,
TaskModifiedEventPayload,
RewardModifiedEventPayload,
} from '@/common/models'
const route = useRoute()
const router = useRouter()
const child = ref<Child | null>(null)
const tasks = ref<string[]>([])
const rewards = ref<string[]>([])
const loading = ref(true)
const error = ref<string | null>(null)
const showConfirm = ref(false)
const selectedTask = ref<Task | null>(null)
const showRewardConfirm = ref(false)
const selectedReward = ref<Reward | null>(null)
const childRewardListRef = ref()
const showPendingRewardDialog = ref(false)
function handleTaskTriggered(event: Event) {
const payload = event.payload as ChildTaskTriggeredEventPayload
if (child.value && payload.child_id == child.value.id) {
child.value.points = payload.points
childRewardListRef.value?.refresh()
}
}
function handleRewardTriggered(event: Event) {
const payload = event.payload as ChildRewardTriggeredEventPayload
if (child.value && payload.child_id == child.value.id) {
child.value.points = payload.points
childRewardListRef.value?.refresh()
}
}
function handleChildTaskSet(event: Event) {
const payload = event.payload as ChildTasksSetEventPayload
if (child.value && payload.child_id == child.value.id) {
tasks.value = payload.task_ids
}
}
function handleChildRewardSet(event: Event) {
const payload = event.payload as ChildRewardsSetEventPayload
if (child.value && payload.child_id == child.value.id) {
rewards.value = payload.reward_ids
}
}
function handleRewardRequest(event: Event) {
const payload = event.payload as ChildRewardRequestEventPayload
const childId = payload.child_id
const rewardId = payload.reward_id
if (child.value && childId == child.value.id) {
if (rewards.value.find((r) => r === rewardId)) {
childRewardListRef.value?.refresh()
}
}
}
function handleTaskModified(event: Event) {
const payload = event.payload as TaskModifiedEventPayload
if (child.value) {
const task_id = payload.task_id
if (tasks.value.includes(task_id)) {
try {
switch (payload.operation) {
case 'DELETE':
// Remove the task from the list
tasks.value = tasks.value.filter((t) => t !== task_id)
return // No need to refetch
case 'ADD':
// A new task was added, this shouldn't affect the current task list
console.log('ADD operation received for task_modified, no action taken.')
return // No need to refetch
case 'EDIT':
try {
const dataPromise = fetchChildData(child.value.id)
dataPromise.then((data) => {
if (data) {
tasks.value = data.tasks || []
}
loading.value = false
})
} catch (err) {
console.warn('Failed to fetch child after EDIT operation:', err)
}
break
default:
console.warn(`Unknown operation: ${payload.operation}`)
return // No need to refetch
}
} catch (err) {
console.warn('Failed to fetch child after task modification:', err)
loading.value = false
}
}
}
}
function handleRewardModified(event: Event) {
const payload = event.payload as RewardModifiedEventPayload
if (child.value) {
const reward_id = payload.reward_id
if (rewards.value.includes(reward_id)) {
childRewardListRef.value?.refresh()
}
}
}
function handleChildModified(event: Event) {
const payload = event.payload as ChildModifiedEventPayload
if (child.value && payload.child_id == child.value.id) {
switch (payload.operation) {
case 'DELETE':
// Navigate away back to children list
router.push({ name: 'ChildrenListView' })
break
case 'ADD':
// A new child was added, this shouldn't affect the current child view
console.log('ADD operation received for child_modified, no action taken.')
break
case 'EDIT':
//our child was edited, refetch its data
try {
const dataPromise = fetchChildData(payload.child_id)
dataPromise.then((data) => {
if (data) {
child.value = data
}
loading.value = false
})
} catch (err) {
console.warn('Failed to fetch child after EDIT operation:', err)
loading.value = false
}
break
default:
console.warn(`Unknown operation: ${payload.operation}`)
}
}
}
async function fetchChildData(id: string | number) {
loading.value = true
try {
const resp = await fetch(`/api/child/${id}`)
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
const data = await resp.json()
error.value = null
return data
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to fetch child'
console.error(err)
return null
} finally {
}
}
onMounted(async () => {
try {
eventBus.on('child_task_triggered', handleTaskTriggered)
eventBus.on('child_reward_triggered', handleRewardTriggered)
eventBus.on('child_tasks_set', handleChildTaskSet)
eventBus.on('child_rewards_set', handleChildRewardSet)
eventBus.on('task_modified', handleTaskModified)
eventBus.on('reward_modified', handleRewardModified)
eventBus.on('child_modified', handleChildModified)
eventBus.on('child_reward_request', handleRewardRequest)
if (route.params.id) {
const idParam = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id
if (idParam !== undefined) {
const promise = fetchChildData(idParam)
promise.then((data) => {
if (data) {
child.value = data
tasks.value = data.tasks || []
rewards.value = data.rewards || []
}
loading.value = false
})
}
}
} catch (err) {
console.error('Error in onMounted:', err)
}
})
onUnmounted(() => {
eventBus.off('child_task_triggered', handleTaskTriggered)
eventBus.off('child_reward_triggered', handleRewardTriggered)
eventBus.off('child_tasks_set', handleChildTaskSet)
eventBus.off('child_rewards_set', handleChildRewardSet)
eventBus.off('child_modified', handleChildModified)
eventBus.off('child_reward_request', handleRewardRequest)
eventBus.off('task_modified', handleTaskModified)
eventBus.off('reward_modified', handleRewardModified)
})
function getPendingRewardIds(): string[] {
const items = childRewardListRef.value?.items || []
return items.filter((item: RewardStatus) => item.redeeming).map((item: RewardStatus) => item.id)
}
const triggerTask = (task: Task) => {
selectedTask.value = task
const pendingRewardIds = getPendingRewardIds()
console.log('Pending reward IDs:', pendingRewardIds)
if (pendingRewardIds.length > 0) {
showPendingRewardDialog.value = true
return
}
showConfirm.value = true
}
async function cancelRewardById(rewardId: string) {
if (!child.value?.id) {
return
}
try {
await fetch(`/api/child/${child.value.id}/cancel-request-reward`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reward_id: rewardId }),
})
} catch (err) {
console.error(`Failed to cancel reward ID ${rewardId}:`, err)
}
}
async function cancelPendingReward() {
if (!child.value?.id) {
showPendingRewardDialog.value = false
return
}
try {
const pendingRewardIds = getPendingRewardIds()
await Promise.all(pendingRewardIds.map((id: string) => cancelRewardById(id)))
childRewardListRef.value?.refresh()
} catch (err) {
console.error('Failed to cancel pending reward:', err)
} finally {
showPendingRewardDialog.value = false
// After cancelling, proceed to trigger the task if one was selected
if (selectedTask.value) {
showConfirm.value = true
}
}
}
const confirmTriggerTask = async () => {
if (!child.value?.id || !selectedTask.value) return
try {
const resp = await fetch(`/api/child/${child.value.id}/trigger-task`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ task_id: selectedTask.value.id }),
})
if (!resp.ok) return
const data = await resp.json()
if (child.value && child.value.id === data.id) child.value.points = data.points
} catch (err) {
console.error('Failed to trigger task:', err)
} finally {
showConfirm.value = false
selectedTask.value = null
}
}
const triggerReward = (reward: RewardStatus) => {
if (reward.points_needed > 0) return
selectedReward.value = reward
showRewardConfirm.value = true
}
const confirmTriggerReward = async () => {
if (!child.value?.id || !selectedReward.value) return
try {
const resp = await fetch(`/api/child/${child.value.id}/trigger-reward`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reward_id: selectedReward.value.id }),
})
if (!resp.ok) return
const data = await resp.json()
if (child.value && child.value.id === data.id) child.value.points = data.points
} catch (err) {
console.error('Failed to trigger reward:', err)
} finally {
showRewardConfirm.value = false
selectedReward.value = null
}
}
function goToAssignTasks() {
if (child.value?.id) {
router.push({ name: 'TaskAssignView', params: { id: child.value.id, type: 'good' } })
}
}
function goToAssignBadHabits() {
if (child.value?.id) {
router.push({ name: 'TaskAssignView', params: { id: child.value.id, type: 'bad' } })
}
}
function goToAssignRewards() {
if (child.value?.id) {
router.push({ name: 'RewardAssignView', params: { id: child.value.id } })
}
}
</script>
<template>
<div>
<div v-if="loading" class="loading">Loading...</div>
<div v-else-if="error" class="error">Error: {{ error }}</div>
<div v-else class="layout">
<div class="main">
<ChildDetailCard :child="child" />
<ScrollingList
title="Chores"
ref="childChoreListRef"
:fetchBaseUrl="`/api/task/list?ids=${tasks.join(',')}`"
:ids="tasks"
itemKey="tasks"
imageField="image_id"
@trigger-item="triggerTask"
:getItemClass="(item) => ({ bad: !item.is_good, good: item.is_good })"
:filter-fn="
(item) => {
return item.is_good
}
"
>
<template #item="{ item }">
<div class="item-name">{{ item.name }}</div>
<img v-if="item.image_url" :src="item.image_url" alt="Task Image" class="item-image" />
<div
class="item-points"
:class="{ 'good-points': item.is_good, 'bad-points': !item.is_good }"
>
{{ item.is_good ? item.points : -item.points }} Points
</div>
</template>
</ScrollingList>
<ScrollingList
title="Penalties"
ref="childPenaltyListRef"
:fetchBaseUrl="`/api/task/list?ids=${tasks.join(',')}`"
:ids="tasks"
itemKey="tasks"
imageField="image_id"
@trigger-item="triggerTask"
:getItemClass="(item) => ({ bad: !item.is_good, good: item.is_good })"
:filter-fn="
(item) => {
return !item.is_good
}
"
>
<template #item="{ item }">
<div class="item-name">{{ item.name }}</div>
<img v-if="item.image_url" :src="item.image_url" alt="Task Image" class="item-image" />
<div
class="item-points"
:class="{ 'good-points': item.is_good, 'bad-points': !item.is_good }"
>
{{ item.is_good ? item.points : -item.points }} Points
</div>
</template>
</ScrollingList>
<ScrollingList
title="Rewards"
ref="childRewardListRef"
:fetchBaseUrl="`/api/child/${child?.id}/reward-status`"
itemKey="reward_status"
imageField="image_id"
@trigger-item="triggerReward"
:getItemClass="(item) => ({ reward: true })"
>
<template #item="{ item }: { item: RewardStatus }">
<div class="item-name">{{ item.name }}</div>
<img
v-if="item.image_url"
:src="item.image_url"
alt="Reward Image"
class="item-image"
/>
<div class="item-points">
<span v-if="item.redeeming" class="pending">PENDING</span>
<span v-if="item.points_needed <= 0" class="ready">REWARD READY</span>
<span v-else>{{ item.points_needed }} more points</span>
</div>
</template>
</ScrollingList>
</div>
</div>
<div class="assign-buttons">
<button v-if="child" class="btn btn-primary" @click="goToAssignTasks">Assign Tasks</button>
<button v-if="child" class="btn btn-danger" @click="goToAssignBadHabits">
Assign Penalties
</button>
<button v-if="child" class="btn btn-green" @click="goToAssignRewards">Assign Rewards</button>
</div>
<!-- Pending Reward Dialog -->
<div v-if="showPendingRewardDialog" class="modal-backdrop">
<div class="modal">
<div class="dialog-message" style="margin-bottom: 1.2rem">
There is a pending reward request. The reward must be cancelled before triggering a new
task.<br />
Would you like to cancel the pending reward?
</div>
<div class="actions">
<button @click="cancelPendingReward" class="btn btn-primary">Yes</button>
<button @click="showPendingRewardDialog = false" class="btn btn-secondary">No</button>
</div>
</div>
</div>
<div v-if="showConfirm && selectedTask" class="modal-backdrop">
<div class="modal">
<div class="task-info">
<img
v-if="selectedTask.image_url"
:src="selectedTask.image_url"
alt="Task Image"
class="task-image"
/>
<div class="task-details">
<div class="task-name">{{ selectedTask.name }}</div>
<div class="task-points" :class="selectedTask.is_good ? 'good' : 'bad'">
{{ selectedTask.points }} points
</div>
</div>
</div>
<div class="dialog-message" style="margin-bottom: 1.2rem">
{{ selectedTask.is_good ? 'Add' : 'Subtract' }} these points
{{ selectedTask.is_good ? 'to' : 'from' }}
<span class="child-name">{{ child?.name }}</span>
</div>
<div class="actions">
<button @click="confirmTriggerTask" class="btn btn-primary">Yes</button>
<button
@click="
() => {
showConfirm = false
selectedTask = null
}
"
class="btn btn-secondary"
>
Cancel
</button>
</div>
</div>
</div>
<div v-if="showRewardConfirm && selectedReward" class="modal-backdrop">
<div class="modal">
<div class="reward-info">
<img
v-if="selectedReward.image_url"
:src="selectedReward.image_url"
alt="Reward Image"
class="reward-image"
/>
<div class="reward-details">
<div class="reward-name">{{ selectedReward.name }}</div>
<div class="reward-points">
{{
selectedReward.points_needed === 0
? 'Reward Ready!'
: selectedReward.points_needed + ' more points'
}}
</div>
</div>
</div>
<div class="dialog-message" style="margin-bottom: 1.2rem">
Redeem this reward for <span class="child-name">{{ child?.name }}</span
>?
</div>
<div class="actions">
<button @click="confirmTriggerReward" class="btn btn-primary">Yes</button>
<button
@click="
() => {
showRewardConfirm = false
selectedReward = null
}
"
class="btn btn-secondary"
>
Cancel
</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.assign-buttons {
display: flex;
gap: 1rem;
justify-content: center;
margin: 2rem 0;
}
.back-btn {
background: var(--back-btn-bg);
border: 0;
padding: 0.6rem 1rem;
border-radius: 8px;
cursor: pointer;
margin-bottom: 1.5rem;
color: var(--back-btn-color);
font-weight: 600;
}
.item-points {
color: var(--item-points-color, #ffd166);
font-size: 1rem;
font-weight: 900;
text-shadow: var(--item-points-shadow);
}
.ready {
color: var(--item-points-ready-color, #38c172);
letter-spacing: 0.5px;
}
.pending {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 80%;
background: var(--pending-block-bg, #222b);
color: var(--pending-block-color, #62ff7a);
font-weight: 700;
font-size: 1.05rem;
text-align: center;
border-radius: 6px;
padding: 0.4rem 0;
letter-spacing: 2px;
display: flex;
align-items: center;
justify-content: center;
z-index: 2;
opacity: 0.95;
pointer-events: none;
}
/* Mobile tweaks */
@media (max-width: 480px) {
.item-points {
font-size: 0.78rem;
}
}
:deep(.good) {
border-color: var(--list-item-border-good);
background: var(--list-item-bg-good);
}
:deep(.bad) {
border-color: var(--list-item-border-bad);
background: var(--list-item-bg-bad);
}
:deep(.reward) {
border-color: var(--list-item-border-reward);
background: var(--list-item-bg-reward);
}
</style>

View File

@@ -0,0 +1,118 @@
<template>
<div class="reward-assign-view">
<h2>Assign Rewards</h2>
<div class="reward-view">
<MessageBlock v-if="rewardCountRef === 0" message="No rewards">
<span> <button class="round-btn" @click="goToCreateReward">Create</button> a reward </span>
</MessageBlock>
<ItemList
v-else
ref="rewardListRef"
:fetchUrl="`/api/child/${childId}/list-all-rewards`"
itemKey="rewards"
:itemFields="REWARD_FIELDS"
imageField="image_id"
selectable
@loading-complete="(count) => (rewardCountRef = count)"
:getItemClass="(item) => `reward`"
>
<template #item="{ item }">
<img v-if="item.image_url" :src="item.image_url" />
<span class="name">{{ item.name }}</span>
<span class="value">{{ item.cost }} pts</span>
</template>
</ItemList>
</div>
<div class="actions" v-if="rewardCountRef != 0">
<button class="btn btn-secondary" @click="onCancel">Cancel</button>
<button class="btn btn-primary" @click="onSubmit">Submit</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import ItemList from '../shared/ItemList.vue'
import MessageBlock from '../shared/MessageBlock.vue'
import '@/assets/actions-shared.css'
import { REWARD_FIELDS } from '@/common/models'
const route = useRoute()
const router = useRouter()
const childId = route.params.id
const rewardListRef = ref()
const rewardCountRef = ref(-1)
function goToCreateReward() {
router.push({ name: 'CreateReward' })
}
async function onSubmit() {
const selectedIds = rewardListRef.value?.selectedItems ?? []
try {
const resp = await fetch(`/api/child/${childId}/set-rewards`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reward_ids: selectedIds }),
})
if (!resp.ok) throw new Error('Failed to update rewards')
router.back()
} catch (err) {
alert('Failed to update rewards.')
}
}
function onCancel() {
router.back()
}
</script>
<style scoped>
.reward-assign-view {
display: flex;
flex-direction: column;
align-items: center;
flex: 1 1 auto;
width: 100%;
height: 100%;
padding: 0;
min-height: 0;
}
.reward-assign-view h2 {
font-size: 1.15rem;
color: var(--assign-heading-color);
font-weight: 700;
text-align: center;
margin: 0.2rem;
}
.reward-view {
display: flex;
flex-direction: column;
align-items: center;
flex: 1 1 auto;
width: 100%;
height: 100%;
padding: 0;
min-height: 0;
}
.name {
flex: 1;
text-align: left;
font-weight: 600;
}
.value {
min-width: 60px;
text-align: right;
font-weight: 600;
}
:deep(.reward) {
border-color: var(--list-item-border-reward);
background: var(--list-item-bg-reward);
}
</style>

View File

@@ -0,0 +1,129 @@
<template>
<div class="task-assign-view">
<h2>Assign Tasks</h2>
<div class="task-view">
<MessageBlock v-if="taskCountRef === 0" message="No tasks">
<span> <button class="round-btn" @click="goToCreateTask">Create</button> a task </span>
</MessageBlock>
<ItemList
v-else
ref="taskListRef"
:fetchUrl="`/api/child/${childId}/list-all-tasks?type=${typeFilter}`"
itemKey="tasks"
:itemFields="TASK_FIELDS"
imageField="image_id"
selectable
@loading-complete="(count) => (taskCountRef = count)"
:getItemClass="(item) => ({ bad: !item.is_good, good: item.is_good })"
>
<template #item="{ item }">
<img v-if="item.image_url" :src="item.image_url" />
<span class="name">{{ item.name }}</span>
<span class="value">{{ item.points }} pts</span>
</template>
</ItemList>
</div>
<div class="actions" v-if="taskCountRef > 0">
<button class="btn btn-secondary" @click="onCancel">Cancel</button>
<button class="btn btn-primary" @click="onSubmit">Submit</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import ItemList from '../shared/ItemList.vue'
import MessageBlock from '../shared/MessageBlock.vue'
import '@/assets/actions-shared.css'
import { TASK_FIELDS } from '@/common/models'
const route = useRoute()
const router = useRouter()
const childId = route.params.id
const taskListRef = ref()
const taskCountRef = ref(-1)
const typeFilter = computed(() => {
if (route.params.type === 'good') return 'good'
if (route.params.type === 'bad') return 'bad'
return 'all'
})
function goToCreateTask() {
router.push({ name: 'CreateTask' })
}
async function onSubmit() {
const selectedIds = taskListRef.value?.selectedItems ?? []
try {
console.log('selectedIds:', selectedIds)
const resp = await fetch(`/api/child/${childId}/set-tasks`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: typeFilter.value, task_ids: selectedIds }),
})
if (!resp.ok) throw new Error('Failed to update tasks')
router.back()
} catch (err) {
alert('Failed to update tasks.')
}
}
function onCancel() {
router.back()
}
</script>
<style scoped>
.task-assign-view {
display: flex;
flex-direction: column;
align-items: center;
flex: 1 1 auto;
width: 100%;
height: 100%;
padding: 0;
min-height: 0;
}
.task-assign-view h2 {
font-size: 1.15rem;
color: var(--assign-heading-color);
font-weight: 700;
text-align: center;
margin: 0.2rem;
}
.task-view {
display: flex;
flex-direction: column;
align-items: center;
flex: 1 1 auto;
width: 100%;
height: 100%;
padding: 0;
min-height: 0;
}
:deep(.good) {
border-color: var(--list-item-border-good);
background: var(--list-item-bg-good);
}
:deep(.bad) {
border-color: var(--list-item-border-bad);
background: var(--list-item-bg-bad);
}
.name {
flex: 1;
text-align: left;
font-weight: 600;
}
.value {
min-width: 60px;
text-align: right;
font-weight: 600;
}
</style>

View File

@@ -0,0 +1,85 @@
<template>
<div class="notification-view">
<ItemList
:fetchUrl="`/api/pending-rewards`"
itemKey="rewards"
:itemFields="PENDING_REWARD_FIELDS"
:imageFields="['child_image_id', 'reward_image_id']"
@clicked="handleNotificationClick"
@loading-complete="(count) => (notificationListCountRef = count)"
>
<template #item="{ item }">
<div class="notification-centered">
<div class="child-info">
<img v-if="item.child_image_url" :src="item.child_image_url" alt="Child" />
<span>{{ item.child_name }}</span>
</div>
<span class="requested-text">requested</span>
<div class="reward-info">
<span>{{ item.reward_name }}</span>
<img v-if="item.reward_image_url" :src="item.reward_image_url" alt="Reward" />
</div>
</div>
</template>
</ItemList>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import ItemList from '../shared/ItemList.vue'
import type { PendingReward } from '@/common/models'
import { PENDING_REWARD_FIELDS } from '@/common/models'
const router = useRouter()
const notificationListCountRef = ref(-1)
function handleNotificationClick(item: PendingReward) {
router.push({ name: 'ParentView', params: { id: item.child_id } })
}
</script>
<style scoped>
.notification-view {
display: flex;
flex-direction: column;
align-items: center;
flex: 1 1 auto;
width: 100%;
height: 100%;
padding: 0;
min-height: 0;
}
.notification-centered {
display: flex;
justify-content: center;
margin-inline: auto;
align-items: center;
}
.child-info {
display: flex;
align-items: center;
gap: 0.4rem;
font-weight: 600;
color: var(--dialog-child-name);
}
.reward-info {
display: flex;
align-items: center;
gap: 0.4rem;
margin-bottom: 0rem;
font-weight: 600;
color: var(--notification-reward-name);
}
.requested-text {
margin: 0 0.7rem;
font-weight: 500;
color: var(--dialog-message);
font-size: 1.05rem;
white-space: nowrap;
}
</style>

View File

@@ -0,0 +1,169 @@
<template>
<div class="profile-view">
<h2>User Profile</h2>
<form class="profile-form" @submit.prevent>
<div class="group">
<label for="child-image">Image</label>
<ImagePicker
id="child-image"
v-model="selectedImageId"
:image-type="1"
@add-image="onAddImage"
/>
</div>
<div class="group">
<label for="first-name">First Name</label>
<input id="first-name" v-model="firstName" type="text" disabled />
</div>
<div class="group">
<label for="last-name">Last Name</label>
<input id="last-name" v-model="lastName" type="text" disabled />
</div>
<div class="group">
<label for="email">Email Address</label>
<input id="email" v-model="email" type="email" disabled />
</div>
<div>
<button type="button" class="btn-link" @click="resetPassword" :disabled="resetting">
{{ resetting ? 'Sending...' : 'Reset Password' }}
</button>
</div>
<div v-if="successMsg" class="success-message" aria-live="polite">{{ successMsg }}</div>
<div v-if="errorMsg" class="error-message" aria-live="polite">{{ errorMsg }}</div>
</form>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import ImagePicker from '@/components/utils/ImagePicker.vue'
import { getCachedImageUrl } from '@/common/imageCache'
import '@/assets/edit-forms.css'
import '@/assets/actions-shared.css'
import '@/assets/button-shared.css'
const firstName = ref('')
const lastName = ref('')
const email = ref('')
const avatarId = ref<string | null>(null)
const avatarUrl = ref('/static/avatar-default.png')
const selectedImageId = ref<string | null>(null)
const localImageFile = ref<File | null>(null)
const errorMsg = ref('')
const successMsg = ref('')
const resetting = ref(false)
onMounted(async () => {
try {
const res = await fetch('/api/user/profile')
if (!res.ok) throw new Error('Failed to load profile')
const data = await res.json()
firstName.value = data.first_name || ''
lastName.value = data.last_name || ''
email.value = data.email || ''
avatarId.value = data.image_id || null
selectedImageId.value = data.image_id || null
// Use imageCache to get avatar URL
if (avatarId.value) {
avatarUrl.value = await getCachedImageUrl(avatarId.value)
} else {
avatarUrl.value = '/static/avatar-default.png'
}
} catch {
errorMsg.value = 'Could not load user profile.'
}
})
// Watch for avatarId changes (e.g., after updating avatar)
watch(avatarId, async (id) => {
if (id) {
avatarUrl.value = await getCachedImageUrl(id)
} else {
avatarUrl.value = '/static/avatar-default.png'
}
})
function onAddImage({ id, file }: { id: string; file: File }) {
if (id === 'local-upload') {
localImageFile.value = file
} else {
localImageFile.value = null
selectedImageId.value = id
updateAvatar(id)
}
}
async function updateAvatar(imageId: string) {
errorMsg.value = ''
successMsg.value = ''
try {
const res = await fetch('/api/user/avatar', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image_id: imageId }),
})
if (!res.ok) throw new Error('Failed to update avatar')
// Update avatarId, which will trigger the watcher to update avatarUrl
avatarId.value = imageId
successMsg.value = 'Avatar updated!'
} catch {
errorMsg.value = 'Failed to update avatar.'
}
}
// If uploading a new image file
watch(localImageFile, async (file) => {
if (!file) return
errorMsg.value = ''
successMsg.value = ''
const formData = new FormData()
formData.append('file', file)
formData.append('type', '2')
formData.append('permanent', 'true')
try {
const resp = await fetch('/api/image/upload', {
method: 'POST',
body: formData,
})
if (!resp.ok) throw new Error('Image upload failed')
const data = await resp.json()
selectedImageId.value = data.id
await updateAvatar(data.id)
} catch {
errorMsg.value = 'Failed to upload avatar image.'
}
})
async function resetPassword() {
resetting.value = true
errorMsg.value = ''
successMsg.value = ''
try {
const res = await fetch('/api/request-password-reset', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email.value }),
})
if (!res.ok) throw new Error('Failed to send reset email')
successMsg.value =
'If this email is registered, you will receive a password reset link shortly.'
} catch {
errorMsg.value = 'Failed to send password reset email.'
} finally {
resetting.value = false
}
}
</script>
<style scoped>
.success-message {
color: var(--success, #16a34a);
font-size: 1rem;
}
.error-message {
color: var(--error, #e53e3e);
font-size: 0.98rem;
margin-top: 0.4rem;
}
</style>

View File

@@ -0,0 +1,180 @@
<script setup lang="ts">
import { ref, onBeforeUnmount, watch, nextTick, computed } from 'vue'
import { defineProps, defineEmits, defineExpose } from 'vue'
import { getCachedImageUrl, revokeAllImageUrls } from '../../common/imageCache'
import type { RewardStatus } from '@/common/models'
const imageCacheName = 'images-v1'
const props = defineProps<{
childId: string | null
childPoints: number
isParentAuthenticated: boolean
}>()
const emit = defineEmits(['trigger-reward'])
const rewards = ref<RewardStatus[]>([])
const loading = ref(true)
const error = ref<string | null>(null)
const scrollWrapper = ref<HTMLDivElement | null>(null)
const rewardRefs = ref<Record<string, HTMLElement | null>>({})
const lastCenteredRewardId = ref<string | null>(null)
const readyRewardId = ref<string | null>(null)
const fetchRewards = async (id: string | number | null) => {
if (!id) {
rewards.value = []
loading.value = false
return
}
loading.value = true
error.value = null
try {
const resp = await fetch(`/api/child/${id}/reward-status`)
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
const data = await resp.json()
rewards.value = data.reward_status
// Fetch images for each reward using shared utility
await Promise.all(rewards.value.map(fetchImage))
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to fetch rewards'
console.error('Error fetching rewards:', err)
} finally {
loading.value = false
}
}
const fetchImage = async (reward: RewardStatus) => {
if (!reward.image_id) {
console.log(`No image ID for reward: ${reward.id}`)
return
}
try {
const url = await getCachedImageUrl(reward.image_id, imageCacheName)
reward.image_id = url
} catch (err) {
console.error('Error fetching image for reward', reward.id, err)
}
}
const centerReward = async (rewardId: string) => {
await nextTick()
const wrapper = scrollWrapper.value
const card = rewardRefs.value[rewardId]
if (wrapper && card) {
const wrapperRect = wrapper.getBoundingClientRect()
const cardRect = card.getBoundingClientRect()
const wrapperScrollLeft = wrapper.scrollLeft
const cardCenter = cardRect.left + cardRect.width / 2
const wrapperCenter = wrapperRect.left + wrapperRect.width / 2
const scrollOffset = cardCenter - wrapperCenter
wrapper.scrollTo({
left: wrapperScrollLeft + scrollOffset,
behavior: 'smooth',
})
}
}
const handleRewardClick = async (rewardId: string) => {
await nextTick()
const wrapper = scrollWrapper.value
const card = rewardRefs.value[rewardId]
if (!wrapper || !card) return
const wrapperRect = wrapper.getBoundingClientRect()
const cardRect = card.getBoundingClientRect()
const cardCenter = cardRect.left + cardRect.width / 2
const cardFullyVisible = cardCenter >= wrapperRect.left && cardCenter <= wrapperRect.right
if (!cardFullyVisible || lastCenteredRewardId.value !== rewardId) {
// Center the reward, but don't trigger
await centerReward(rewardId)
lastCenteredRewardId.value = rewardId
readyRewardId.value = rewardId
return
}
// If already centered and visible, trigger the reward
await triggerReward(rewardId)
readyRewardId.value = null
}
const triggerReward = (rewardId: string) => {
const reward = rewards.value.find((rew) => rew.id === rewardId)
if (!reward) return // Don't trigger if not allowed
emit('trigger-reward', reward, reward.points_needed <= 0, reward.redeeming)
}
watch(
() => props.childId,
(newId) => {
fetchRewards(newId)
},
{ immediate: true },
)
watch(
() => props.childPoints,
() => {
rewards.value.forEach((reward) => {
reward.points_needed = Math.max(0, reward.cost - props.childPoints)
})
},
)
function getPendingRewards(): string[] {
return rewards.value.filter((r) => r.redeeming).map((r) => r.id)
}
// revoke created object URLs when component unmounts to avoid memory leaks
onBeforeUnmount(() => {
revokeAllImageUrls()
})
// expose refresh method for parent component
defineExpose({ refresh: () => fetchRewards(props.childId), getPendingRewards })
const isAnyPending = computed(() => rewards.value.some((r) => r.redeeming))
</script>
<template>
<div class="child-list-container">
<h3>Rewards</h3>
<div v-if="loading" class="loading">Loading rewards...</div>
<div v-else-if="error" class="error">Error: {{ error }}</div>
<div v-else-if="rewards.length === 0" class="empty">No rewards available</div>
<div v-else class="scroll-wrapper" ref="scrollWrapper">
<div class="item-scroll">
<div
v-for="r in rewards"
:key="r.id"
class="item-card"
:class="{
ready: readyRewardId === r.id,
disabled: isAnyPending && !r.redeeming,
}"
:ref="(el) => (rewardRefs[r.id] = el)"
@click="() => handleRewardClick(r.id)"
>
<div class="item-name">{{ r.name }}</div>
<img v-if="r.image_id" :src="r.image_id" alt="Reward Image" class="item-image" />
<div class="item-points" :class="{ ready: r.points_needed === 0 }">
<template v-if="r.points_needed === 0"> REWARD READY </template>
<template v-else> {{ r.points_needed }} more points </template>
</div>
<!-- PENDING block if redeeming is true -->
<div v-if="r.redeeming" class="pending-block">PENDING</div>
</div>
</div>
</div>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,169 @@
<template>
<div class="reward-edit-view">
<h2>{{ isEdit ? 'Edit Reward' : 'Create Reward' }}</h2>
<div v-if="loading" class="loading-message">Loading reward...</div>
<form v-else @submit.prevent="submit" class="reward-form">
<div class="group">
<label>
Reward Name
<input ref="nameInput" v-model="name" type="text" required maxlength="64" />
</label>
</div>
<div class="group">
<label>
Description
<input v-model="description" type="text" maxlength="128" />
</label>
</div>
<div class="group">
<label>
Cost
<input v-model.number="cost" type="number" min="1" max="1000" required />
</label>
</div>
<div class="group">
<label for="reward-image">Image</label>
<ImagePicker
id="reward-image"
v-model="selectedImageId"
:image-type="2"
@add-image="onAddImage"
/>
</div>
<div v-if="error" class="error">{{ error }}</div>
<div class="actions">
<button type="button" @click="handleCancel" :disabled="loading" class="btn btn-secondary">
Cancel
</button>
<button type="submit" :disabled="loading" class="btn btn-primary">
{{ isEdit ? 'Save' : 'Create' }}
</button>
</div>
</form>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import ImagePicker from '@/components/utils/ImagePicker.vue'
import '@/assets/edit-forms.css'
const props = defineProps<{ id?: string }>()
const route = useRoute()
const router = useRouter()
const isEdit = computed(() => !!props.id)
const name = ref('')
const description = ref('')
const cost = ref(1)
const selectedImageId = ref<string | null>(null)
const localImageFile = ref<File | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
const nameInput = ref<HTMLInputElement | null>(null)
onMounted(async () => {
if (isEdit.value && props.id) {
loading.value = true
try {
const resp = await fetch(`/api/reward/${props.id}`)
if (!resp.ok) throw new Error('Failed to load reward')
const data = await resp.json()
name.value = data.name
description.value = data.description ?? ''
cost.value = Number(data.cost) || 1
selectedImageId.value = data.image_id ?? null
} catch (e) {
error.value = 'Could not load reward.'
} finally {
loading.value = false
await nextTick()
nameInput.value?.focus()
}
} else {
await nextTick()
nameInput.value?.focus()
}
})
function onAddImage({ id, file }: { id: string; file: File }) {
if (id === 'local-upload') {
localImageFile.value = file
}
}
function handleCancel() {
router.back()
}
const submit = async () => {
let imageId = selectedImageId.value
error.value = null
if (!name.value.trim()) {
error.value = 'Reward name is required.'
return
}
if (cost.value < 1) {
error.value = 'Cost must be at least 1.'
return
}
loading.value = true
// If the selected image is a local upload, upload it first
if (imageId === 'local-upload' && localImageFile.value) {
const formData = new FormData()
formData.append('file', localImageFile.value)
formData.append('type', '2')
formData.append('permanent', 'false')
try {
const resp = await fetch('/api/image/upload', {
method: 'POST',
body: formData,
})
if (!resp.ok) throw new Error('Image upload failed')
const data = await resp.json()
imageId = data.id
} catch (err) {
alert('Failed to upload image.')
loading.value = false
return
}
}
// Now update or create the reward
try {
let resp
if (isEdit.value && props.id) {
resp = await fetch(`/api/reward/${props.id}/edit`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: name.value,
description: description.value,
cost: cost.value,
image_id: imageId,
}),
})
} else {
resp = await fetch('/api/reward/add', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: name.value,
description: description.value,
cost: cost.value,
image_id: imageId,
}),
})
}
if (!resp.ok) throw new Error('Failed to save reward')
await router.push({ name: 'RewardView' })
} catch (err) {
alert('Failed to save reward.')
}
loading.value = false
}
</script>
<style scoped></style>

View File

@@ -0,0 +1,112 @@
<template>
<div class="reward-view">
<MessageBlock v-if="rewardCountRef === 0" message="No rewards">
<span> <button class="round-btn" @click="createReward">Create</button> a reward </span>
</MessageBlock>
<ItemList
v-else
fetchUrl="/api/reward/list"
itemKey="rewards"
:itemFields="REWARD_FIELDS"
imageField="image_id"
deletable
@clicked="(reward: Reward) => $router.push({ name: 'EditReward', params: { id: reward.id } })"
@delete="confirmDeleteReward"
@loading-complete="(count) => (rewardCountRef = count)"
:getItemClass="(item) => `reward`"
>
<template #item="{ item }">
<img v-if="item.image_url" :src="item.image_url" />
<span class="name">{{ item.name }}</span>
<span class="value">{{ item.cost }} pts</span>
</template>
</ItemList>
<FloatingActionButton aria-label="Create Reward" @click="createReward" />
<DeleteModal
:show="showConfirm"
message="Are you sure you want to delete this reward?"
@confirm="deleteReward"
@cancel="showConfirm = false"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import ItemList from '../shared/ItemList.vue'
import MessageBlock from '@/components/shared/MessageBlock.vue'
import '@/assets/button-shared.css'
import FloatingActionButton from '../shared/FloatingActionButton.vue'
import DeleteModal from '../shared/DeleteModal.vue'
import type { Reward } from '@/common/models'
import { REWARD_FIELDS } from '@/common/models'
import '@/assets/view-shared.css'
const $router = useRouter()
const showConfirm = ref(false)
const rewardToDelete = ref<string | null>(null)
const rewardListRef = ref()
const rewardCountRef = ref<number>(-1)
function confirmDeleteReward(rewardId: string) {
rewardToDelete.value = rewardId
showConfirm.value = true
}
const deleteReward = async () => {
if (!rewardToDelete.value) return
try {
const resp = await fetch(`/api/reward/${rewardToDelete.value}`, {
method: 'DELETE',
})
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
// Refresh the reward list after successful delete
rewardListRef.value?.refresh()
} catch (err) {
console.error('Failed to delete reward:', err)
} finally {
showConfirm.value = false
rewardToDelete.value = null
}
}
const createReward = () => {
$router.push({ name: 'CreateReward' })
}
</script>
<style scoped>
.reward-view {
display: flex;
flex-direction: column;
align-items: center;
flex: 1 1 auto;
width: 100%;
height: 100%;
padding: 0;
min-height: 0;
}
.name {
flex: 1;
text-align: left;
font-weight: 600;
}
.value {
min-width: 60px;
text-align: right;
font-weight: 600;
}
:deep(.reward) {
border-color: var(--list-item-border-reward);
background: var(--list-item-bg-reward);
}
</style>

View File

@@ -0,0 +1,530 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { getCachedImageUrl, revokeAllImageUrls } from '../../common/imageCache'
import { isParentAuthenticated } from '../../stores/auth'
import { eventBus } from '@/common/eventBus'
import type {
Child,
ChildModifiedEventPayload,
ChildTaskTriggeredEventPayload,
ChildRewardTriggeredEventPayload,
Event,
} from '@/common/models'
import MessageBlock from '@/components/shared/MessageBlock.vue'
import '@/assets/button-shared.css'
import FloatingActionButton from '../shared/FloatingActionButton.vue'
import DeleteModal from '../shared/DeleteModal.vue'
const router = useRouter()
const children = ref<Child[]>([])
const loading = ref(true)
const error = ref<string | null>(null)
const images = ref<Map<string, string>>(new Map()) // Store image URLs
const imageCacheName = 'images-v1'
// UI state for kebab menus & delete confirmation
const activeMenuFor = ref<string | number | null>(null) // which child card shows menu
const confirmDeleteVisible = ref(false)
const deletingChildId = ref<string | number | null>(null)
const deleting = ref(false)
const openChildEditor = (child: Child, evt?: Event) => {
evt?.stopPropagation()
router.push({ name: 'ChildEditView', params: { id: child.id } })
}
async function handleChildModified(event: Event) {
const payload = event.payload as ChildModifiedEventPayload
const childId = payload.child_id
switch (payload.operation) {
case 'DELETE':
children.value = children.value.filter((c) => c.id !== childId)
break
case 'ADD':
try {
const list = await fetchChildren()
children.value = list
} catch (err) {
console.warn('Failed to fetch children after ADD operation:', err)
}
break
case 'EDIT':
try {
const list = await fetchChildren()
const updatedChild = list.find((c) => c.id === childId)
if (updatedChild) {
const idx = children.value.findIndex((c) => c.id === childId)
if (idx !== -1) {
children.value[idx] = updatedChild
} else {
console.warn(`EDIT operation: child with id ${childId} not found in current list.`)
}
} else {
console.warn(
`EDIT operation: updated child with id ${childId} not found in fetched list.`,
)
}
} catch (err) {
console.warn('Failed to fetch children after EDIT operation:', err)
}
break
default:
console.warn(`Unknown operation: ${payload.operation}`)
}
}
function handleChildTaskTriggered(event: Event) {
const payload = event.payload as ChildTaskTriggeredEventPayload
const childId = payload.child_id
const child = children.value.find((c) => c.id === childId)
if (child) {
child.points = payload.points
} else {
console.warn(`Child with id ${childId} not found when updating points.`)
}
}
function handleChildRewardTriggered(event: Event) {
const payload = event.payload as ChildRewardTriggeredEventPayload
const childId = payload.child_id
const child = children.value.find((c) => c.id === childId)
if (child) {
child.points = payload.points
} else {
console.warn(`Child with id ${childId} not found when updating points.`)
}
}
// points update state
const updatingPointsFor = ref<string | number | null>(null)
const fetchImage = async (imageId: string) => {
try {
const url = await getCachedImageUrl(imageId, imageCacheName)
images.value.set(imageId, url)
} catch (err) {
console.warn('Failed to load child image', imageId, err)
}
}
const fetchChildren = async (): Promise<Child[]> => {
loading.value = true
error.value = null
images.value.clear()
try {
const response = await fetch('/api/child/list')
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const data = await response.json()
const childList = data.children || []
// Fetch images for each child (shared cache util)
await Promise.all(
childList.map((child) => {
if (child.image_id) {
return fetchImage(child.image_id)
}
return Promise.resolve()
}),
)
return childList
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to fetch children'
console.error('Error fetching children:', err)
return []
} finally {
loading.value = false
}
}
const createChild = () => {
router.push({ name: 'CreateChild' })
}
onMounted(async () => {
eventBus.on('child_modified', handleChildModified)
eventBus.on('child_task_triggered', handleChildTaskTriggered)
eventBus.on('child_reward_triggered', handleChildRewardTriggered)
const listPromise = fetchChildren()
listPromise.then((list) => {
children.value = list
})
// listen for outside clicks to auto-close any open kebab menu
document.addEventListener('click', onDocClick, true)
})
onUnmounted(() => {
eventBus.off('child_modified', handleChildModified)
eventBus.off('child_task_triggered', handleChildTaskTriggered)
eventBus.off('child_reward_triggered', handleChildRewardTriggered)
})
const shouldIgnoreNextCardClick = ref(false)
const onDocClick = (e: MouseEvent) => {
if (activeMenuFor.value !== null) {
const path = (e.composedPath && e.composedPath()) || (e as any).path || []
const clickedInsideKebab = path.some((node: unknown) => {
if (!(node instanceof HTMLElement)) return false
return (
node.classList.contains('kebab-wrap') ||
node.classList.contains('kebab-btn') ||
node.classList.contains('kebab-menu')
)
})
if (!clickedInsideKebab) {
activeMenuFor.value = null
// If the click was on a card, set the flag to ignore the next card click
if (
path.some((node: unknown) => node instanceof HTMLElement && node.classList.contains('card'))
) {
shouldIgnoreNextCardClick.value = true
}
}
}
}
const selectChild = (childId: string | number) => {
if (shouldIgnoreNextCardClick.value) {
shouldIgnoreNextCardClick.value = false
return
}
if (activeMenuFor.value !== null) {
// If kebab menu is open, ignore card clicks
return
}
if (isParentAuthenticated.value) {
router.push(`/parent/${childId}`)
} else {
router.push(`/child/${childId}`)
}
}
// kebab menu helpers
const openMenu = (childId: string | number, evt?: Event) => {
evt?.stopPropagation()
activeMenuFor.value = childId
}
const closeMenu = () => {
activeMenuFor.value = null
}
// delete flow
const askDelete = (childId: string | number, evt?: Event) => {
evt?.stopPropagation()
deletingChildId.value = childId
confirmDeleteVisible.value = true
closeMenu()
}
const performDelete = async () => {
if (!deletingChildId.value) return
deleting.value = true
try {
const resp = await fetch(`/api/child/${deletingChildId.value}`, {
method: 'DELETE',
})
if (!resp.ok) {
throw new Error(`Delete failed: ${resp.status}`)
}
// refresh list
await fetchChildren()
} catch (err) {
console.error('Failed to delete child', deletingChildId.value, err)
} finally {
deleting.value = false
confirmDeleteVisible.value = false
deletingChildId.value = null
}
}
// Delete Points flow: set points to 0 via API and refresh points display
const deletePoints = async (childId: string | number, evt?: Event) => {
evt?.stopPropagation()
closeMenu()
updatingPointsFor.value = childId
try {
const resp = await fetch(`/api/child/${childId}/edit`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ points: 0 }),
})
if (!resp.ok) {
throw new Error(`Failed to update points: ${resp.status}`)
}
// no need to refresh since we update optimistically via eventBus
} catch (err) {
console.error('Failed to delete points for child', childId, err)
} finally {
updatingPointsFor.value = null
}
}
onBeforeUnmount(() => {
document.removeEventListener('click', onDocClick, true)
revokeAllImageUrls()
})
</script>
<template>
<div>
<MessageBlock v-if="children.length === 0" message="No children">
<span v-if="!isParentAuthenticated">
<button class="round-btn" @click="eventBus.emit('open-login')">Sign in</button> to create a
child
</span>
<span v-else> <button class="round-btn" @click="createChild">Create</button> a child </span>
</MessageBlock>
<div v-else-if="loading" class="loading">Loading...</div>
<div v-else-if="error" class="error">Error: {{ error }}</div>
<div v-else class="grid">
<div v-for="child in children" :key="child.id" class="card" @click="selectChild(child.id)">
<!-- kebab menu shown only for authenticated parent -->
<div v-if="isParentAuthenticated" class="kebab-wrap" @click.stop>
<!-- kebab button -->
<button
class="kebab-btn"
@mousedown.stop.prevent
@click="openMenu(child.id, $event)"
:aria-expanded="activeMenuFor === child.id ? 'true' : 'false'"
aria-label="Options"
>
</button>
<!-- menu items -->
<div
v-if="activeMenuFor === child.id"
class="kebab-menu"
@mousedown.stop.prevent
@click.stop
>
<button
class="menu-item"
@mousedown.stop.prevent
@click="openChildEditor(child, $event)"
>
Edit Child
</button>
<button
class="menu-item"
@mousedown.stop.prevent
@click="deletePoints(child.id, $event)"
:disabled="updatingPointsFor === child.id"
>
{{ updatingPointsFor === child.id ? 'Updating…' : 'Delete Points' }}
</button>
<button class="menu-item danger" @mousedown.stop.prevent @click="askDelete(child.id)">
Delete Child
</button>
</div>
</div>
<div class="card-content">
<h2>{{ child.name }}</h2>
<img
v-if="images.get(child.image_id)"
:src="images.get(child.image_id)"
alt="Child Image"
class="child-image"
/>
<p class="age">Age: {{ child.age }}</p>
<p class="points">Points: {{ child.points ?? 0 }}</p>
</div>
</div>
</div>
<DeleteModal
:show="confirmDeleteVisible"
message="Are you sure you want to delete this child?"
@confirm="performDelete"
@cancel="confirmDeleteVisible = false"
/>
<FloatingActionButton
v-if="isParentAuthenticated"
aria-label="Add Child"
@click="createChild"
/>
</div>
</template>
<style scoped>
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1.2rem 1.2rem;
justify-items: center;
}
.card {
background: var(--card-bg);
box-shadow: var(--card-shadow);
border-radius: 12px;
overflow: visible; /* allow menu to overflow */
transition: all 0.3s ease;
display: flex;
flex-direction: column;
cursor: pointer;
position: relative; /* for kebab positioning */
width: 250px;
}
/* kebab button / menu (fixed-size button, absolutely positioned menu) */
.kebab-wrap {
position: absolute;
top: 8px;
right: 8px;
z-index: 20;
/* keep the wrapper only as a positioning context */
}
.kebab-btn {
width: 36px;
height: 36px;
display: inline-flex;
align-items: center;
justify-content: center;
background: transparent;
border: 0;
padding: 0;
cursor: pointer;
color: var(--kebab-icon-color);
border-radius: 6px;
box-sizing: border-box;
font-size: 1.5rem;
}
/* consistent focus ring without changing layout */
.kebab-btn:focus {
outline: none;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.18);
}
/* Menu overlays the card and does NOT alter flow */
.kebab-menu {
position: absolute;
top: 44px;
right: 0;
margin: 0;
min-width: 150px;
background: var(--kebab-menu-bg);
border: 1.5px solid var(--kebab-menu-border);
box-shadow: var(--kebab-menu-shadow);
backdrop-filter: blur(var(--kebab-menu-blur));
display: flex;
flex-direction: column;
overflow: hidden;
z-index: 30;
}
.menu-item {
padding: 1.1rem 0.9rem; /* Increase vertical padding for bigger touch area */
background: transparent;
border: 0;
text-align: left;
cursor: pointer;
font-weight: 600;
color: var(--menu-item-color);
font-size: 1.1rem; /* Slightly larger text for readability */
}
.menu-item + .menu-item {
margin-top: 0.5rem; /* Add space between menu items */
}
@media (max-width: 600px) {
.menu-item {
padding: 0.85rem 0.7rem;
font-size: 1rem;
}
.menu-item + .menu-item {
margin-top: 0.35rem;
}
}
.menu-item:hover {
background: var(--menu-item-hover-bg);
}
.menu-item.danger {
color: var(--menu-item-danger);
}
/* card content */
.card-content {
padding: 1.5rem;
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.card h2 {
font-size: 1.5rem;
color: var(--card-title);
margin-bottom: 0.5rem;
word-break: break-word;
text-align: center;
}
.age {
font-size: 1.1rem;
color: var(--age-color);
font-weight: 500;
text-align: center;
}
.child-image {
width: 100px;
height: 100px;
object-fit: cover;
border-radius: 50%;
margin: 0 auto 1rem auto;
background: var(--child-image-bg);
}
.points {
font-size: 1.05rem;
color: var(--points-color);
margin-top: 0.4rem;
font-weight: 600;
text-align: center;
}
/* Loading, error, empty states */
.loading,
.empty {
margin: 1.2rem 0;
color: var(--list-loading-color);
font-size: 1.15rem;
font-weight: 600;
text-align: center;
line-height: 1.5;
}
.error {
color: var(--error);
margin-top: 0.7rem;
text-align: center;
background: var(--error-bg);
border-radius: 8px;
padding: 1rem;
}
.value {
font-weight: 600;
margin-left: 1rem;
}
</style>

View File

@@ -0,0 +1,48 @@
<template>
<div class="modal-backdrop" v-if="show">
<div class="modal">
<p>{{ message }}</p>
<div class="actions">
<button @click="handleDelete" class="btn btn-danger" :disabled="deleting">
{{ deleting ? 'Deleting' : 'Delete' }}
</button>
<button @click="handleCancel" class="btn btn-secondary" :disabled="deleting">Cancel</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
const props = defineProps<{
show: boolean
message?: string
}>()
const emit = defineEmits(['confirm', 'cancel'])
const deleting = ref(false)
function handleDelete() {
deleting.value = true
emit('confirm')
}
function handleCancel() {
if (!deleting.value) emit('cancel')
}
// Reset deleting state when modal is closed
watch(
() => props.show,
(val) => {
if (!val) deleting.value = false
},
)
</script>
<style>
@import '@/assets/modal.css';
@import '@/assets/actions-shared.css';
</style>

View File

@@ -0,0 +1,6 @@
<template>
<div v-if="message" class="error-message" aria-live="polite">{{ message }}</div>
</template>
<script setup lang="ts">
defineProps<{ message: string }>()
</script>

View File

@@ -0,0 +1,45 @@
<template>
<button class="fab" @click="$emit('click')" :aria-label="ariaLabel">
<svg width="28" height="28" viewBox="0 0 28 28" fill="none">
<circle cx="14" cy="14" r="14" fill="#667eea" />
<path d="M14 8v12M8 14h12" stroke="#fff" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
</template>
<script setup lang="ts">
import '@/assets/global.css'
defineProps<{ ariaLabel?: string }>()
</script>
<style scoped>
.fab {
position: fixed;
bottom: 2rem;
right: 2rem;
background: var(--fab-bg);
color: #fff;
border: none;
border-radius: 50%;
width: 56px;
height: 56px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
cursor: pointer;
font-size: 24px;
z-index: 1300;
}
.fab:hover {
background: var(--fab-hover-bg);
}
.fab:active {
background: var(--fab-active-bg);
}
svg {
display: block;
}
</style>

View File

@@ -0,0 +1,267 @@
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
import { getCachedImageUrl } from '@/common/imageCache'
const props = defineProps<{
fetchUrl: string
itemKey: string
itemFields: readonly string[]
imageFields?: readonly string[]
selectable?: boolean
deletable?: boolean
onClicked?: (item: any) => void
onDelete?: (id: string) => void
filterFn?: (item: any) => boolean
getItemClass?: (item: any) => string | string[] | Record<string, boolean>
}>()
const emit = defineEmits(['clicked', 'delete', 'loading-complete'])
const items = ref<any[]>([])
const loading = ref(true)
const error = ref<string | null>(null)
const selectedItems = ref<string[]>([])
defineExpose({
items,
selectedItems,
})
const fetchItems = async () => {
loading.value = true
error.value = null
console.log(`Fetching items from: ${props.fetchUrl}`)
try {
const resp = await fetch(props.fetchUrl)
console.log(`Fetch response status: ${resp.status}`)
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
const data = await resp.json()
//console log all data
console.log('Fetched data:', data)
let itemList = data[props.itemKey || 'items'] || []
if (props.filterFn) itemList = itemList.filter(props.filterFn)
const initiallySelected: string[] = []
await Promise.all(
itemList.map(async (item: any) => {
if (props.imageFields) {
for (const field of props.imageFields) {
if (item[field]) {
try {
item[`${field.replace('_id', '_url')}`] = await getCachedImageUrl(item[field])
} catch {
console.error('Error fetching image for item', item.id)
item[`${field.replace('_id', '_url')}`] = null
}
}
}
} else if (item.image_id) {
try {
item.image_url = await getCachedImageUrl(item.image_id)
} catch {
item.image_url = null
}
}
//for each item see it there is an 'assigned' field that is true. if so check the item's selectable checkbox
if (props.selectable && item.assigned === true) {
initiallySelected.push(item.id)
}
}),
)
items.value = itemList
if (props.selectable) {
selectedItems.value = initiallySelected
}
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to fetch items'
items.value = []
if (props.selectable) selectedItems.value = []
} finally {
emit('loading-complete', items.value.length)
loading.value = false
}
}
onMounted(fetchItems)
watch(() => props.fetchUrl, fetchItems)
const handleClicked = (item: any) => {
emit('clicked', item)
props.onClicked?.(item)
}
const handleDelete = (item: any) => {
emit('delete', item)
props.onDelete?.(item)
}
</script>
<template>
<div class="listbox">
<div v-if="loading" class="loading">Loading...</div>
<div v-else-if="error" class="error">{{ error }}</div>
<div v-else-if="items.length === 0" class="empty">No items found.</div>
<div v-else>
<div v-for="(item, idx) in items" :key="item.id" class="list-row">
<div :class="['list-item', props.getItemClass?.(item)]" @click.stop="handleClicked(item)">
<slot name="item" :item="item">
<!-- Default rendering if no slot is provided -->
<img v-if="item.image_url" :src="item.image_url" />
<span class="list-name">List Item</span>
<span class="list-value">1</span>
</slot>
<div v-if="props.selectable || props.deletable" class="interact">
<input
v-if="props.selectable"
type="checkbox"
class="list-checkbox"
v-model="selectedItems"
:value="item.id"
@click.stop
/>
<button
v-if="props.deletable"
class="delete-btn"
@click.stop="handleDelete(item)"
aria-label="Delete item"
type="button"
>
<!-- SVG icon here -->
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
<circle cx="10" cy="10" r="9" fill="#fff" stroke="#ef4444" stroke-width="2" />
<path
d="M7 7l6 6M13 7l-6 6"
stroke="#ef4444"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
</button>
</div>
<div v-if="idx < items.length - 1" class="list-separator"></div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.listbox {
flex: 0 1 auto;
max-width: 480px;
width: 100%;
max-height: calc(100vh - 4.5rem);
overflow-y: auto;
margin: 0.2rem 0 0 0;
display: flex;
flex-direction: column;
gap: 0.7rem;
background: var(--list-bg);
padding: 0.2rem 0.2rem 0.2rem;
border-radius: 12px;
}
.list-item {
display: flex;
align-items: center;
border: 2px outset var(--list-item-border-reward);
border-radius: 8px;
padding: 0.2rem 1rem;
background: var(--list-item-bg);
font-size: 1.05rem;
font-weight: 500;
transition: border 0.18s;
margin-bottom: 0.2rem;
margin-left: 0.2rem;
margin-right: 0.2rem;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.03);
box-sizing: border-box;
justify-content: space-between;
cursor: pointer;
}
.list-item .interact {
display: flex;
align-items: center;
gap: 0.5rem;
}
/* Image styles */
:deep(.list-item img) {
width: 36px;
height: 36px;
object-fit: cover;
border-radius: 8px;
margin-right: 0.7rem;
background: var(--list-image-bg);
flex-shrink: 0;
}
/* Name/label styles */
.list-name {
flex: 1;
text-align: left;
font-weight: 600;
}
/* Points/cost/requested text */
.list-value {
min-width: 60px;
text-align: right;
font-weight: 600;
}
.delete-btn {
background: transparent;
border: none;
border-radius: 50%;
margin-left: 0.7rem;
cursor: pointer;
display: flex;
align-items: center;
transition:
background 0.15s,
box-shadow 0.15s;
width: 2rem;
height: 2rem;
opacity: 0.92;
}
.delete-btn:hover {
background: var(--delete-btn-hover-bg);
box-shadow: 0 0 0 2px var(--delete-btn-hover-shadow);
opacity: 1;
}
.delete-btn svg {
display: block;
}
/* Checkbox */
.list-checkbox {
margin-left: 1rem;
width: 1.2em;
height: 1.2em;
accent-color: var(--checkbox-accent);
cursor: pointer;
}
/* Loading, error, empty states */
.loading,
.empty {
margin: 1.2rem 0;
color: var(--list-loading-color);
font-size: 1.15rem;
font-weight: 600;
text-align: center;
line-height: 1.5;
}
.error {
color: var(--error);
margin-top: 0.7rem;
text-align: center;
background: var(--error-bg);
border-radius: 8px;
padding: 1rem;
}
/* Separator (if needed) */
.list-separator {
height: 0px;
background: #0000;
margin: 0rem 0.2rem;
border-radius: 0px;
}
</style>

View File

@@ -0,0 +1,190 @@
<script setup lang="ts">
import { ref, nextTick, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { eventBus } from '@/common/eventBus'
import { authenticateParent, isParentAuthenticated, logoutParent } from '../../stores/auth'
import '@/assets/modal.css'
import '@/assets/actions-shared.css'
const router = useRouter()
const show = ref(false)
const pin = ref('')
const error = ref('')
const pinInput = ref<HTMLInputElement | null>(null)
const dropdownOpen = ref(false)
const open = async () => {
pin.value = ''
error.value = ''
show.value = true
await nextTick()
pinInput.value?.focus()
}
const close = () => {
show.value = false
error.value = ''
}
const submit = () => {
const isDigits = /^\d{4,6}$/.test(pin.value)
if (!isDigits) {
error.value = 'Enter 46 digits'
return
}
if (pin.value !== '1179') {
error.value = 'Incorrect PIN'
return
}
// Authenticate parent and navigate
authenticateParent()
close()
router.push('/parent')
}
const handleLogout = () => {
logoutParent()
router.push('/child')
}
function toggleDropdown() {
dropdownOpen.value = !dropdownOpen.value
}
async function signOut() {
try {
await fetch('/api/logout', { method: 'POST' })
logoutParent()
router.push('/auth')
} catch {
// Optionally show error
}
dropdownOpen.value = false
}
function goToProfile() {
router.push('/parent/profile')
}
onMounted(() => {
eventBus.on('open-login', open)
})
onUnmounted(() => {
eventBus.off('open-login', open)
})
</script>
<template>
<div style="position: relative">
<button v-if="!isParentAuthenticated" @click="open" aria-label="Parent login" class="login-btn">
Parent
</button>
<div v-else style="display: inline-block; position: relative">
<button
@click="toggleDropdown"
aria-label="Parent menu"
class="login-btn"
style="min-width: 80px"
>
Parent
</button>
<div
v-if="dropdownOpen"
class="dropdown-menu"
style="
position: absolute;
right: 0;
top: 100%;
background: #fff;
border: 1px solid #e6e6e6;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
min-width: 120px;
z-index: 10;
"
>
<button class="menu-item" @click="goToProfile" style="width: 100%; text-align: left">
Profile
</button>
<button class="menu-item" @click="handleLogout" style="width: 100%; text-align: left">
Log out
</button>
<button class="menu-item danger" @click="signOut" style="width: 100%; text-align: left">
Sign out
</button>
</div>
</div>
<div v-if="show" class="modal-backdrop" @click.self="close">
<div class="modal">
<h3>Enter parent PIN</h3>
<form @submit.prevent="submit">
<input
ref="pinInput"
v-model="pin"
inputmode="numeric"
pattern="\d*"
maxlength="6"
placeholder="46 digits"
class="pin-input"
/>
<div class="actions">
<button type="button" class="btn btn-secondary" @click="close">Cancel</button>
<button type="submit" class="btn btn-primary">OK</button>
</div>
</form>
<div v-if="error" class="error">{{ error }}</div>
</div>
</div>
</div>
</template>
<style>
/* modal */
.pin-input {
width: 100%;
padding: 0.5rem 0.6rem;
font-size: 1rem;
border-radius: 8px;
border: 1px solid #e6e6e6;
margin-bottom: 0.6rem;
box-sizing: border-box;
text-align: center;
}
.dropdown-menu {
padding: 0.5rem 0;
}
.menu-item {
padding: 1rem 0.9rem;
background: transparent;
border: 0;
text-align: left;
cursor: pointer;
font-weight: 600;
color: var(--menu-item-color, #333);
font-size: 0.9rem;
}
.menu-item + .menu-item {
margin-top: 0.5rem;
}
.menu-item:hover {
background: var(--menu-item-hover-bg, rgba(0, 0, 0, 0.04));
}
.menu-item.danger {
color: var(--menu-item-danger, #ff4d4f);
}
@media (max-width: 600px) {
.menu-item {
padding: 0.85rem 0.7rem;
font-size: 1rem;
}
.menu-item + .menu-item {
margin-top: 0.35rem;
}
}
</style>

View File

@@ -0,0 +1,28 @@
<template>
<div class="message-block">
<div>{{ message }}</div>
<div class="sub-message">
<slot />
</div>
</div>
</template>
<script setup lang="ts">
import '@/assets/global.css'
defineProps<{ message: string }>()
</script>
<style scoped>
.message-block {
margin: 2rem 0;
font-size: 1.15rem;
font-weight: 600;
text-align: center;
color: var(--message-block-color);
line-height: 1.5;
}
.sub-message {
margin-top: 0.3rem;
font-size: 1rem;
font-weight: 400;
color: var(--sub-message-color);
}
</style>

View File

@@ -0,0 +1,32 @@
<template>
<div class="modal-backdrop">
<div class="modal-dialog">
<slot />
</div>
</div>
</template>
<script setup lang="ts">
// No script content needed unless you want to add props or logic
</script>
<style scoped>
.modal-backdrop {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.35);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-dialog {
background: #fff;
padding: 2rem;
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
max-width: 340px;
text-align: center;
}
</style>

View File

@@ -0,0 +1,312 @@
<script setup lang="ts">
import { ref, watch, onBeforeUnmount, nextTick, computed } from 'vue'
import { getCachedImageUrl, revokeAllImageUrls } from '@/common/imageCache'
const props = defineProps<{
title: string
fetchBaseUrl: string
ids?: readonly string[]
itemKey: string
imageFields?: readonly string[]
isParentAuthenticated?: boolean
filterFn?: (item: any) => boolean
getItemClass?: (item: any) => string | string[] | Record<string, boolean>
}>()
// Compute the fetch URL with ids if present
const fetchUrl = computed(() => {
if (props.ids && props.ids.length > 0) {
const separator = props.fetchBaseUrl.includes('?') ? '&' : '?'
return `${props.fetchBaseUrl}${separator}ids=${props.ids.join(',')}`
}
return props.fetchBaseUrl
})
const emit = defineEmits<{
(e: 'trigger-item', item: any): void
}>()
const items = ref<any[]>([])
const loading = ref(true)
const error = ref<string | null>(null)
const scrollWrapper = ref<HTMLDivElement | null>(null)
const itemRefs = ref<Record<string, HTMLElement | Element | null>>({})
const lastCenteredItemId = ref<string | null>(null)
const readyItemId = ref<string | null>(null)
const fetchItems = async () => {
loading.value = true
error.value = null
try {
const resp = await fetch(fetchUrl.value)
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
const data = await resp.json()
// Try to use 'tasks', 'reward_status', or 'items' as fallback
let itemList = data[props.itemKey || 'items'] || []
if (props.filterFn) itemList = itemList.filter(props.filterFn)
items.value = itemList
// Fetch images for each item
await Promise.all(
itemList.map(async (item: any) => {
if (props.imageFields) {
for (const field of props.imageFields) {
if (item[field]) {
try {
item[`${field.replace('_id', '_url')}`] = await getCachedImageUrl(item[field])
} catch {
item[`${field.replace('_id', '_url')}`] = null
}
}
}
} else if (item.image_id) {
try {
item.image_url = await getCachedImageUrl(item.image_id)
} catch {
console.error('Error fetching image for item', item.id)
item.image_url = null
}
}
}),
)
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to fetch items'
items.value = []
console.error('Error fetching items:', err)
} finally {
loading.value = false
}
}
async function refresh() {
await fetchItems()
loading.value = false
}
defineExpose({
refresh,
items,
})
const centerItem = async (itemId: string) => {
await nextTick()
const wrapper = scrollWrapper.value
const card = itemRefs.value[itemId]
if (wrapper && card) {
const wrapperRect = wrapper.getBoundingClientRect()
const cardRect = card.getBoundingClientRect()
const wrapperScrollLeft = wrapper.scrollLeft
const cardCenter = cardRect.left + cardRect.width / 2
const wrapperCenter = wrapperRect.left + wrapperRect.width / 2
const scrollOffset = cardCenter - wrapperCenter
wrapper.scrollTo({
left: wrapperScrollLeft + scrollOffset,
behavior: 'smooth',
})
}
}
const handleClicked = async (item: any) => {
await nextTick()
const wrapper = scrollWrapper.value
const card = itemRefs.value[item.id]
if (!wrapper || !card) return
const wrapperRect = wrapper.getBoundingClientRect()
const cardRect = card.getBoundingClientRect()
const cardCenter = cardRect.left + cardRect.width / 2
const cardFullyVisible = cardCenter >= wrapperRect.left && cardCenter <= wrapperRect.right
if (!cardFullyVisible || lastCenteredItemId.value !== item.id) {
// Center the item, but don't trigger
await centerItem(item.id)
lastCenteredItemId.value = item.id
readyItemId.value = item.id
return
}
emit(
'trigger-item',
items.value.find((i) => i.id === item.id),
)
readyItemId.value = null
}
watch(
() => [props.ids],
() => {
fetchItems()
},
{ immediate: true },
)
onBeforeUnmount(() => {
revokeAllImageUrls()
})
</script>
<template>
<div class="child-list-container">
<h3>{{ title }}</h3>
<div v-if="loading" class="loading">Loading...</div>
<div v-else-if="error" class="error">Error: {{ error }}</div>
<div v-else-if="items.length === 0" class="empty">No {{ title }}</div>
<div v-else class="scroll-wrapper" ref="scrollWrapper">
<div class="item-scroll">
<div
v-for="item in items"
:key="item.id"
:class="['item-card', props.getItemClass?.(item)]"
:ref="(el) => (itemRefs[item.id] = el)"
@click.stop="handleClicked(item)"
>
<slot name="item" :item="item">
<div class="item-name">{{ item.name }}</div>
</slot>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.child-list-container {
background: var(--child-list-bg, rgba(255, 255, 255, 0.1));
border-radius: 12px;
padding: 1rem;
color: var(--child-list-title-color, #fff);
width: 100%;
box-sizing: border-box;
}
.scroll-wrapper {
overflow-x: auto;
overflow-y: hidden;
scroll-behavior: smooth;
width: 100%;
-webkit-overflow-scrolling: touch;
}
.scroll-wrapper::-webkit-scrollbar {
height: 8px;
}
.scroll-wrapper::-webkit-scrollbar-track {
background: var(--child-list-scrollbar-track, rgba(255, 255, 255, 0.05));
border-radius: 10px;
}
.scroll-wrapper::-webkit-scrollbar-thumb {
background: var(
--child-list-scrollbar-thumb,
linear-gradient(180deg, rgba(102, 126, 234, 0.8), rgba(118, 75, 162, 0.8))
);
border-radius: 10px;
border: var(--child-list-scrollbar-thumb-border, 2px solid rgba(255, 255, 255, 0.08));
}
.scroll-wrapper::-webkit-scrollbar-thumb:hover {
background: var(
--child-list-scrollbar-thumb-hover,
linear-gradient(180deg, rgba(102, 126, 234, 1), rgba(118, 75, 162, 1))
);
box-shadow: 0 0 8px rgba(102, 126, 234, 0.4);
}
.item-scroll {
display: flex;
gap: 0.75rem;
min-width: min-content;
padding: 0.5rem 0;
}
/* Fallback for browsers that don't support flex gap */
.item-card + .item-card {
margin-left: 0.75rem;
}
.item-card {
position: relative;
background: var(--item-card-bg, rgba(255, 255, 255, 0.12));
border-radius: 8px;
padding: 0.75rem;
min-width: 140px;
max-width: 220px;
width: 100%;
text-align: center;
flex-shrink: 0;
transition: transform 0.18s ease;
border: var(--item-card-border, 1px solid rgba(255, 255, 255, 0.08));
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
cursor: pointer;
user-select: none; /* Prevent image selection */
}
.item-card:hover {
transform: translateY(-4px);
box-shadow: var(--item-card-hover-shadow, 0 8px 20px rgba(0, 0, 0, 0.12));
}
@keyframes ready-glow {
0% {
box-shadow: 0 0 0 0 #667eea00;
border-color: inherit;
}
100% {
box-shadow: var(--item-card-ready-shadow, 0 0 0 3px #667eea88, 0 0 12px #667eea44);
border-color: var(--item-card-ready-border, #667eea);
}
}
:deep(.item-name) {
font-size: 0.95rem;
font-weight: 700;
margin-bottom: 0.4rem;
color: var(--item-name-color, #fff);
line-height: 1.2;
word-break: break-word;
}
/* Image styling */
:deep(.item-image) {
width: 70px;
height: 70px;
object-fit: cover;
border-radius: 6px;
margin: 0 auto 0.4rem auto;
display: block;
}
/* Mobile tweaks */
@media (max-width: 480px) {
.item-card {
min-width: 110px;
max-width: 150px;
padding: 0.6rem;
}
:deep(.item-name) {
font-size: 0.86rem;
}
:deep(.item-image) {
width: 50px;
height: 50px;
margin: 0 auto 0.3rem auto;
}
.scroll-wrapper::-webkit-scrollbar {
height: 10px;
}
.scroll-wrapper::-webkit-scrollbar-thumb {
border-width: 1px;
}
}
.loading,
.error,
.empty {
text-align: center;
padding: 2rem 0;
color: #888;
}
</style>

View File

@@ -0,0 +1,6 @@
<template>
<div v-if="message" class="success-message" aria-live="polite">{{ message }}</div>
</template>
<script setup lang="ts">
defineProps<{ message: string }>()
</script>

View File

@@ -0,0 +1,171 @@
<script setup lang="ts">
import { ref, watch, onBeforeUnmount, nextTick, computed } from 'vue'
import { defineProps, defineEmits } from 'vue'
import { getCachedImageUrl, revokeAllImageUrls } from '../../common/imageCache'
import type { Task } from '@/common/models'
const imageCacheName = 'images-v1'
const props = defineProps<{
title: string
taskIds: string[]
childId: string | number | null
isParentAuthenticated: boolean
filterType?: number | null
}>()
const emit = defineEmits<{
(e: 'trigger-task', task: Task): void
}>()
const tasks = ref<Task[]>([])
const loading = ref(true)
const error = ref<string | null>(null)
const scrollWrapper = ref<HTMLDivElement | null>(null)
const taskRefs = ref<Record<string, HTMLElement | null>>({})
const lastCenteredTaskId = ref<string | null>(null)
const readyTaskId = ref<string | null>(null)
const fetchTasks = async () => {
const taskPromises = props.taskIds.map((id) =>
fetch(`/api/task/${id}`).then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json()
}),
)
try {
const results = await Promise.all(taskPromises)
tasks.value = results
// Fetch images for each task (uses shared imageCache)
await Promise.all(tasks.value.map(fetchImage))
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to fetch tasks'
console.error('Error fetching tasks:', err)
} finally {
loading.value = false
}
}
const fetchImage = async (task: Task) => {
if (!task.image_id) {
console.log(`No image ID for task: ${task.id}`)
return
}
try {
const url = await getCachedImageUrl(task.image_id, imageCacheName)
task.image_url = url
} catch (err) {
console.error('Error fetching image for task', task.id, err)
}
}
const centerTask = async (taskId: string) => {
await nextTick()
const wrapper = scrollWrapper.value
const card = taskRefs.value[taskId]
if (wrapper && card) {
const wrapperRect = wrapper.getBoundingClientRect()
const cardRect = card.getBoundingClientRect()
const wrapperScrollLeft = wrapper.scrollLeft
const cardCenter = cardRect.left + cardRect.width / 2
const wrapperCenter = wrapperRect.left + wrapperRect.width / 2
const scrollOffset = cardCenter - wrapperCenter
wrapper.scrollTo({
left: wrapperScrollLeft + scrollOffset,
behavior: 'smooth',
})
}
}
const triggerTask = (taskId: string) => {
const task = tasks.value.find((t) => t.id === taskId)
if (task) emit('trigger-task', task)
}
const handleTaskClick = async (taskId: string) => {
await nextTick()
const wrapper = scrollWrapper.value
const card = taskRefs.value[taskId]
if (!wrapper || !card) return
const wrapperRect = wrapper.getBoundingClientRect()
const cardRect = card.getBoundingClientRect()
const cardCenter = cardRect.left + cardRect.width / 2
const cardFullyVisible = cardCenter >= wrapperRect.left && cardCenter <= wrapperRect.right
if (!cardFullyVisible || lastCenteredTaskId.value !== taskId) {
// Center the task, but don't trigger
await centerTask(taskId)
lastCenteredTaskId.value = taskId
readyTaskId.value = taskId
return
}
// If already centered and visible, emit to parent
triggerTask(taskId)
readyTaskId.value = null
}
const filteredTasks = computed(() => {
if (props.filterType == 1) {
return tasks.value.filter((t) => t.is_good)
} else if (props.filterType == 2) {
return tasks.value.filter((t) => !t.is_good)
}
return tasks.value
})
watch(
() => props.taskIds,
(newTaskIds) => {
if (newTaskIds && newTaskIds.length > 0) {
fetchTasks()
} else {
tasks.value = []
loading.value = false
}
},
{ immediate: true },
)
// revoke all created object URLs when component unmounts
onBeforeUnmount(() => {
revokeAllImageUrls()
})
</script>
<template>
<div class="child-list-container">
<h3>{{ title }}</h3>
<div v-if="loading" class="loading">Loading tasks...</div>
<div v-else-if="error" class="error">Error: {{ error }}</div>
<div v-else-if="filteredTasks.length === 0" class="empty">No {{ title }}</div>
<div v-else class="scroll-wrapper" ref="scrollWrapper">
<div class="item-scroll">
<div
v-for="task in filteredTasks"
:key="task.id"
class="item-card"
:class="{ good: task.is_good, bad: !task.is_good, ready: readyTaskId === task.id }"
:ref="(el) => (taskRefs[task.id] = el)"
@click="() => handleTaskClick(task.id)"
>
<div class="item-name">{{ task.name }}</div>
<img v-if="task.image_url" :src="task.image_url" alt="Task Image" class="task-image" />
<div
class="item-points"
:class="{ 'good-points': task.is_good, 'bad-points': !task.is_good }"
>
{{ task.is_good ? task.points : -task.points }} Points
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,255 @@
<script setup lang="ts">
import { ref, onMounted, computed, defineEmits, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import ImagePicker from '@/components/utils/ImagePicker.vue'
import '@/assets/edit-forms.css'
const route = useRoute()
const router = useRouter()
const isEdit = computed(() => !!route.params.id)
const emit = defineEmits<{
(e: 'updated'): void
}>()
// Define props
const props = defineProps<{
id?: string
}>()
const name = ref('')
const points = ref(0)
const isGood = ref(true)
const selectedImageId = ref<string | null>(null)
const localImageFile = ref<File | null>(null)
const nameInput = ref<HTMLInputElement | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
// Load task if editing
onMounted(async () => {
if (isEdit.value) {
loading.value = true
try {
const resp = await fetch(`/api/task/${props.id}`)
if (!resp.ok) throw new Error('Failed to load task')
const data = await resp.json()
name.value = data.name
points.value = Number(data.points) || 0
isGood.value = data.is_good
selectedImageId.value = data.image_id
} catch (e) {
error.value = 'Could not load task.'
} finally {
loading.value = false
// Delay focus until after DOM updates and event propagation
await nextTick()
nameInput.value?.focus()
}
} else {
// For create, also use nextTick
await nextTick()
nameInput.value?.focus()
}
})
const submit = async () => {
let imageId = selectedImageId.value
error.value = null
if (!name.value.trim()) {
error.value = 'Task name is required.'
return
}
if (points.value < 1) {
error.value = 'Points must be at least 1.'
return
}
loading.value = true
// If the selected image is a local upload, upload it first
if (imageId === 'local-upload' && localImageFile.value) {
const formData = new FormData()
formData.append('file', localImageFile.value)
formData.append('type', '2')
formData.append('permanent', 'false')
try {
const resp = await fetch('/api/image/upload', {
method: 'POST',
body: formData,
})
if (!resp.ok) throw new Error('Image upload failed')
const data = await resp.json()
imageId = data.id
} catch (err) {
alert('Failed to upload image.')
loading.value = false
return
}
}
// Now update or create the task
try {
let resp
if (isEdit.value) {
resp = await fetch(`/api/task/${props.id}/edit`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: name.value,
points: points.value,
is_good: isGood.value,
image_id: imageId,
}),
})
} else {
resp = await fetch('/api/task/add', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: name.value,
points: points.value,
is_good: isGood.value,
image_id: imageId,
}),
})
}
if (!resp.ok) throw new Error('Failed to save task')
emit('updated')
await router.push({ name: 'TaskView' })
} catch (err) {
alert('Failed to save task.')
}
loading.value = false
}
function handleCancel() {
router.back()
}
// Handle new image from ImagePicker
function onAddImage({ id, file }: { id: string; file: File }) {
if (id === 'local-upload') {
localImageFile.value = file
}
}
</script>
<template>
<div class="task-edit-view">
<h2>{{ isEdit ? 'Edit Task' : 'Create Task' }}</h2>
<div v-if="loading" class="loading-message">Loading task...</div>
<form v-else @submit.prevent="submit" class="task-form">
<div class="group">
<label for="task-name">
Task Name
<input
id="task-name"
ref="nameInput"
v-model="name"
type="text"
required
maxlength="64"
/>
</label>
</div>
<div class="group">
<label for="task-points">
Task Points
<input
id="task-points"
v-model.number="points"
type="number"
min="1"
max="100"
required
/>
</label>
</div>
<div class="group">
<label for="task-type">
Task Type
<div class="good-bad-toggle" id="task-type">
<button
type="button"
:class="['toggle-btn', isGood ? 'good-active' : '']"
@click="isGood = true"
>
Good
</button>
<button
type="button"
:class="['toggle-btn', !isGood ? 'bad-active' : '']"
@click="isGood = false"
>
Bad
</button>
</div>
</label>
</div>
<div class="group">
<label for="task-image">Image</label>
<ImagePicker
id="task-image"
v-model="selectedImageId"
:image-type="2"
@add-image="onAddImage"
/>
</div>
<div v-if="error" class="error">{{ error }}</div>
<div class="actions">
<button type="button" @click="handleCancel" :disabled="loading" class="btn btn-secondary">
Cancel
</button>
<button type="submit" :disabled="loading" class="btn btn-primary">
{{ isEdit ? 'Save' : 'Create' }}
</button>
</div>
</form>
</div>
</template>
<style scoped>
.good-bad-toggle {
display: flex;
gap: 0.5rem;
margin-bottom: 1.1rem;
justify-content: flex-start;
}
button.toggle-btn {
flex: 1 1 0;
padding: 0.5rem 1.2rem;
border-width: 2px;
border-radius: 7px;
font-weight: 600;
font-size: 1rem;
cursor: pointer;
transition:
background 0.18s,
color 0.18s,
border-style 0.18s;
outline: none;
border-style: outset; /* Default style */
background: var(--toggle-btn-bg);
color: var(--toggle-btn-color);
border-color: var(--toggle-btn-border);
}
button.toggle-btn.good-active {
background: var(--toggle-btn-good-bg);
color: var(--toggle-btn-good-color);
box-shadow: 0 2px 8px var(--toggle-btn-good-shadow);
transform: translateY(2px) scale(0.97);
border-style: ridge;
border-color: var(--toggle-btn-good-border);
}
button.toggle-btn.bad-active {
background: var(--toggle-btn-bad-bg);
color: var(--toggle-btn-bad-color);
box-shadow: 0 2px 8px var(--toggle-btn-bad-shadow);
transform: translateY(2px) scale(0.97);
border-style: ridge;
border-color: var(--toggle-btn-bad-border);
}
</style>

View File

@@ -0,0 +1,116 @@
<template>
<div class="task-view">
<MessageBlock v-if="taskCountRef === 0" message="No tasks">
<span> <button class="round-btn" @click="createTask">Create</button> a task </span>
</MessageBlock>
<ItemList
v-else
fetchUrl="/api/task/list"
itemKey="tasks"
:itemFields="TASK_FIELDS"
imageField="image_id"
deletable
@clicked="(task: Task) => $router.push({ name: 'EditTask', params: { id: task.id } })"
@delete="confirmDeleteTask"
@loading-complete="(count) => (taskCountRef = count)"
:getItemClass="(item) => ({ bad: !item.is_good, good: item.is_good })"
>
<template #item="{ item }">
<img v-if="item.image_url" :src="item.image_url" />
<span class="name">{{ item.name }}</span>
<span class="value">{{ item.points }} pts</span>
</template>
</ItemList>
<FloatingActionButton aria-label="Create Task" @click="createTask" />
<DeleteModal
:show="showConfirm"
message="Are you sure you want to delete this task?"
@confirm="deleteTask"
@cancel="showConfirm = false"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import ItemList from '../shared/ItemList.vue'
import MessageBlock from '@/components/shared/MessageBlock.vue'
import '@/assets/button-shared.css'
import FloatingActionButton from '../shared/FloatingActionButton.vue'
import DeleteModal from '../shared/DeleteModal.vue'
import type { Task } from '@/common/models'
import { TASK_FIELDS } from '@/common/models'
const $router = useRouter()
const showConfirm = ref(false)
const taskToDelete = ref<string | null>(null)
const taskListRef = ref()
const taskCountRef = ref<number>(-1)
function confirmDeleteTask(taskId: string) {
taskToDelete.value = taskId
showConfirm.value = true
}
const deleteTask = async () => {
if (!taskToDelete.value) return
try {
const resp = await fetch(`/api/task/${taskToDelete.value}`, {
method: 'DELETE',
})
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
// Refresh the task list after successful delete
taskListRef.value?.refresh()
} catch (err) {
console.error('Failed to delete task:', err)
} finally {
showConfirm.value = false
taskToDelete.value = null
}
}
// New function to handle task creation
const createTask = () => {
// Route to your create task page or open a create dialog
// Example:
$router.push({ name: 'CreateTask' })
}
</script>
<style scoped>
.task-view {
display: flex;
flex-direction: column;
align-items: center;
flex: 1 1 auto;
width: 100%;
height: 100%;
padding: 0;
min-height: 0;
}
.name {
flex: 1;
text-align: left;
font-weight: 600;
}
.value {
min-width: 60px;
text-align: right;
font-weight: 600;
}
:deep(.good) {
border-color: var(--list-item-border-good);
background: var(--list-item-bg-good);
}
:deep(.bad) {
border-color: var(--list-item-border-bad);
background: var(--list-item-bg-bad);
}
</style>

View File

@@ -0,0 +1,372 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, nextTick, 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 class="picker">
<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>
.picker {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
}
.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>