starting refactor styling
This commit is contained in:
@@ -93,10 +93,57 @@
|
||||
font-weight: 600;
|
||||
margin-left: 6px;
|
||||
}
|
||||
.btn-link.btn-disabled {
|
||||
.btn-link:disabled {
|
||||
text-decoration: none;
|
||||
opacity: 0.75;
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
color: var(--btn-primary);
|
||||
}
|
||||
|
||||
.round-btn {
|
||||
background: var(--sign-in-btn-bg);
|
||||
color: var(--sign-in-btn-color);
|
||||
border: 2px solid var(--sign-in-btn-border);
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
padding: 0.2rem 0.5rem;
|
||||
margin-right: 0.1rem;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.18s,
|
||||
color 0.18s;
|
||||
}
|
||||
.round-btn:hover {
|
||||
background: var(--sign-in-btn-hover-bg);
|
||||
color: var(--sign-in-btn-hover-color);
|
||||
}
|
||||
|
||||
/* Floating Action Button (FAB) */
|
||||
.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);
|
||||
}
|
||||
|
||||
@@ -14,15 +14,6 @@
|
||||
color: var(--child-list-title-color, #fff);
|
||||
}
|
||||
|
||||
.loading,
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
font-size: 0.9rem;
|
||||
opacity: 0.8;
|
||||
color: var(--child-list-loading-color, #fff);
|
||||
}
|
||||
|
||||
.scroll-wrapper {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
|
||||
29
web/vue-app/src/assets/common.css
Normal file
29
web/vue-app/src/assets/common.css
Normal file
@@ -0,0 +1,29 @@
|
||||
.loading,
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
font-size: 0.9rem;
|
||||
opacity: 0.8;
|
||||
color: var(--child-list-loading-color, #fff);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--error);
|
||||
margin-top: 0.7rem;
|
||||
text-align: center;
|
||||
background: var(--error-bg);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: var(--error, #e53e3e);
|
||||
font-size: 0.98rem;
|
||||
margin-top: 0.4rem;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.success-message {
|
||||
color: var(--success, #16a34a);
|
||||
font-size: 1rem;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
.profile-view,
|
||||
.edit-view,
|
||||
.child-edit-view,
|
||||
.reward-edit-view,
|
||||
@@ -10,6 +11,7 @@
|
||||
padding: 2rem 2.2rem 1.5rem 2.2rem;
|
||||
}
|
||||
|
||||
.profile-view h2,
|
||||
.edit-view h2,
|
||||
.child-edit-view h2,
|
||||
.reward-edit-view h2,
|
||||
@@ -19,22 +21,38 @@
|
||||
color: var(--form-heading);
|
||||
}
|
||||
|
||||
.form-group,
|
||||
.reward-form label,
|
||||
.task-form label {
|
||||
display: block;
|
||||
margin-bottom: 1.1rem;
|
||||
font-weight: 500;
|
||||
color: var(--form-label);
|
||||
width: 100%;
|
||||
.profile-form,
|
||||
.task-form,
|
||||
.reward-form,
|
||||
.child-edit-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.1rem;
|
||||
}
|
||||
|
||||
input[type='text'],
|
||||
input[type='number'],
|
||||
.reward-form input[type='text'],
|
||||
.reward-form input[type='number'],
|
||||
.task-form input[type='text'],
|
||||
.task-form input[type='number'] {
|
||||
.profile-form div.group,
|
||||
.task-form div.group,
|
||||
.reward-form div.group,
|
||||
.child-edit-form div.group {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.profile-form div.group label,
|
||||
.task-form div.group label,
|
||||
.reward-form div.group label,
|
||||
.child-edit-form div.group label {
|
||||
font-weight: 600;
|
||||
color: var(--form-label-color);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
div.group input[type='text'],
|
||||
div.group input[type='number'],
|
||||
div.group input[type='email'] {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 0.4rem;
|
||||
@@ -45,9 +63,18 @@ input[type='number'],
|
||||
background: var(--form-input-bg);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
div.group input:focus {
|
||||
outline: none;
|
||||
border: 1.5px solid var(--form-input-focus);
|
||||
}
|
||||
|
||||
.loading-message {
|
||||
text-align: center;
|
||||
color: var(--form-loading);
|
||||
margin-bottom: 1.2rem;
|
||||
}
|
||||
|
||||
.form-group.image-picker-group {
|
||||
display: block;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
@@ -71,8 +71,8 @@
|
||||
--fab-bg: #667eea;
|
||||
--fab-hover-bg: #5a67d8;
|
||||
--fab-active-bg: #4c51bf;
|
||||
--no-children-color: #fdfdfd;
|
||||
--sub-message-color: #5d719d;
|
||||
--message-block-color: #fdfdfd;
|
||||
--sub-message-color: #c1d0f1;
|
||||
--sign-in-btn-bg: #fff;
|
||||
--sign-in-btn-color: #2563eb;
|
||||
--sign-in-btn-border: #2563eb;
|
||||
|
||||
@@ -14,57 +14,6 @@
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
/* List item */
|
||||
.list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: 2px outset var(--list-item-border-good);
|
||||
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;
|
||||
}
|
||||
.list-item.bad {
|
||||
border-color: var(--list-item-border-bad);
|
||||
background: var(--list-item-bg-bad);
|
||||
}
|
||||
.list-item.good {
|
||||
border-color: var(--list-item-border-good);
|
||||
background: var(--list-item-bg-good);
|
||||
}
|
||||
|
||||
/* Image styles */
|
||||
.list-image {
|
||||
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 button */
|
||||
.delete-btn {
|
||||
background: transparent;
|
||||
|
||||
25
web/vue-app/src/assets/modal.css
Normal file
25
web/vue-app/src/assets/modal.css
Normal file
@@ -0,0 +1,25 @@
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1200;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--modal-bg);
|
||||
color: var(--modal-text);
|
||||
padding: 1.25rem;
|
||||
border-radius: 10px;
|
||||
width: 360px;
|
||||
max-width: calc(100% - 32px);
|
||||
box-shadow: var(--modal-shadow);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.modal h3 {
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
25
web/vue-app/src/assets/scroll.css
Normal file
25
web/vue-app/src/assets/scroll.css
Normal file
@@ -0,0 +1,25 @@
|
||||
.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);
|
||||
}
|
||||
@@ -94,21 +94,6 @@
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.no-message {
|
||||
margin: 2rem 0;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
color: #fdfdfd;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.sub-message {
|
||||
margin-top: 0.3rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 400;
|
||||
color: #b5ccff;
|
||||
}
|
||||
|
||||
/* Floating Action Button (FAB) */
|
||||
.fab {
|
||||
position: fixed;
|
||||
|
||||
@@ -2,16 +2,16 @@
|
||||
<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="form">
|
||||
<div class="form-group">
|
||||
<form v-else @submit.prevent="submit" class="child-edit-form">
|
||||
<div class="group">
|
||||
<label for="child-name">Name</label>
|
||||
<input id="child-name" ref="nameInput" v-model="name" required maxlength="64" />
|
||||
<input type="text" id="child-name" ref="nameInput" v-model="name" required maxlength="64" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<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="form-group image-picker-group">
|
||||
<div class="group">
|
||||
<label for="child-image">Image</label>
|
||||
<ImagePicker
|
||||
id="child-image"
|
||||
@@ -154,44 +154,4 @@ function onCancel() {
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.1rem;
|
||||
}
|
||||
.form-group {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.35rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
label {
|
||||
font-weight: 600;
|
||||
color: var(--form-label-color);
|
||||
font-size: 1rem;
|
||||
}
|
||||
input[type='text'],
|
||||
input[type='number'] {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
padding: 0.5rem 0.7rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--form-input-border);
|
||||
font-size: 1rem;
|
||||
background: var(--form-input-bg);
|
||||
color: var(--form-input-color);
|
||||
transition: border 0.2s;
|
||||
}
|
||||
input:focus {
|
||||
outline: none;
|
||||
border: 1.5px solid var(--form-input-focus);
|
||||
}
|
||||
.form-group.image-picker-group {
|
||||
display: block;
|
||||
text-align: left;
|
||||
}
|
||||
</style>
|
||||
<style scoped></style>
|
||||
|
||||
169
web/vue-app/src/components/profile/UserProfile.vue
Normal file
169
web/vue-app/src/components/profile/UserProfile.vue
Normal 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>
|
||||
@@ -3,19 +3,25 @@
|
||||
<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">
|
||||
<label>
|
||||
Reward Name
|
||||
<input ref="nameInput" v-model="name" type="text" required maxlength="64" />
|
||||
</label>
|
||||
<label>
|
||||
Description
|
||||
<input v-model="description" type="text" maxlength="128" />
|
||||
</label>
|
||||
<label>
|
||||
Cost
|
||||
<input v-model.number="cost" type="number" min="1" max="1000" required />
|
||||
</label>
|
||||
<div class="form-group image-picker-group">
|
||||
<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"
|
||||
|
||||
@@ -11,6 +11,10 @@ import type {
|
||||
ChildRewardTriggeredEventPayload,
|
||||
Event,
|
||||
} from '@/common/models'
|
||||
import MessageBlock from '@/components/shared/MessageBlock.vue'
|
||||
import '@/assets/common.css'
|
||||
import '@/assets/modal.css'
|
||||
import '@/assets/button-shared.css'
|
||||
|
||||
const router = useRouter()
|
||||
const children = ref<Child[]>([])
|
||||
@@ -273,16 +277,13 @@ onBeforeUnmount(() => {
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="children.length === 0" class="no-message">
|
||||
<div>No children</div>
|
||||
<div class="sub-message">
|
||||
<template v-if="!isParentAuthenticated">
|
||||
<button class="sign-in-btn" @click="eventBus.emit('open-login')">Sign in</button> to
|
||||
create a child
|
||||
</template>
|
||||
<span v-else><button class="sign-in-btn" @click="createChild">Create</button> a child</span>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
@@ -383,35 +384,6 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: white;
|
||||
margin-bottom: 2rem;
|
||||
font-size: 2.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 200px;
|
||||
color: white;
|
||||
font-size: 1.2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
@@ -547,33 +519,6 @@ h1 {
|
||||
background: var(--child-image-bg);
|
||||
}
|
||||
|
||||
/* modal */
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1200;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--modal-bg);
|
||||
color: var(--modal-text);
|
||||
padding: 1.25rem;
|
||||
border-radius: 10px;
|
||||
width: 360px;
|
||||
max-width: calc(100% - 32px);
|
||||
box-shadow: var(--modal-shadow);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.modal h3 {
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.points {
|
||||
font-size: 1.05rem;
|
||||
color: var(--points-color);
|
||||
@@ -581,23 +526,4 @@ h1 {
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sign-in-btn {
|
||||
background: var(--sign-in-btn-bg);
|
||||
color: var(--sign-in-btn-color);
|
||||
border: 2px solid var(--sign-in-btn-border);
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
padding: 0.2rem 0.5rem;
|
||||
margin-right: 0.1rem;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.18s,
|
||||
color 0.18s;
|
||||
}
|
||||
.sign-in-btn:hover {
|
||||
background: var(--sign-in-btn-hover-bg);
|
||||
color: var(--sign-in-btn-hover-color);
|
||||
}
|
||||
</style>
|
||||
|
||||
163
web/vue-app/src/components/shared/ItemList.vue
Normal file
163
web/vue-app/src/components/shared/ItemList.vue
Normal file
@@ -0,0 +1,163 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import { getCachedImageUrl } from '@/common/imageCache'
|
||||
|
||||
const props = defineProps<{
|
||||
fetchUrl: string
|
||||
itemKey: string
|
||||
itemFields: string[]
|
||||
imageField?: string
|
||||
selectable?: boolean
|
||||
deletable?: boolean
|
||||
onEdit?: (id: string) => void
|
||||
onDelete?: (id: string) => void
|
||||
filterFn?: (item: any) => boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['edit', 'delete', 'loading-complete'])
|
||||
|
||||
const items = ref<any[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
const selectedItems = ref<string[]>([])
|
||||
|
||||
const fetchItems = async () => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const resp = await fetch(props.fetchUrl)
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
const data = await resp.json()
|
||||
let itemList = data[props.itemKey] || []
|
||||
if (props.filterFn) itemList = itemList.filter(props.filterFn)
|
||||
await Promise.all(
|
||||
itemList.map(async (item: any) => {
|
||||
if (props.imageField && item[props.imageField]) {
|
||||
try {
|
||||
item.image_url = await getCachedImageUrl(item[props.imageField])
|
||||
} catch {
|
||||
item.image_url = null
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
items.value = itemList
|
||||
if (props.selectable) selectedItems.value = []
|
||||
} 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 handleEdit = (id: string) => {
|
||||
emit('edit', id)
|
||||
props.onEdit?.(id)
|
||||
}
|
||||
const handleDelete = (id: string) => {
|
||||
emit('delete', id)
|
||||
props.onDelete?.(id)
|
||||
}
|
||||
</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">
|
||||
<div class="list-item" @click="handleEdit(item.id)">
|
||||
<img v-if="item.image_url" :src="item.image_url" alt="Item" class="list-image" />
|
||||
<span class="list-name">{{ item.name }}</span>
|
||||
<span v-if="item.points !== undefined" class="list-points">{{ item.points }} pts</span>
|
||||
<span v-if="item.cost !== undefined" class="list-cost">{{ item.cost }} pts</span>
|
||||
<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.id)"
|
||||
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>
|
||||
</template>
|
||||
<style scoped>
|
||||
.list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: 2px outset var(--list-item-border-good);
|
||||
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;
|
||||
}
|
||||
.list-item.bad {
|
||||
border-color: var(--list-item-border-bad);
|
||||
background: var(--list-item-bg-bad);
|
||||
}
|
||||
.list-item.good {
|
||||
border-color: var(--list-item-border-good);
|
||||
background: var(--list-item-bg-good);
|
||||
}
|
||||
|
||||
/* Image styles */
|
||||
.list-image {
|
||||
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;
|
||||
}
|
||||
</style>
|
||||
@@ -3,6 +3,8 @@ 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)
|
||||
@@ -62,6 +64,10 @@ async function signOut() {
|
||||
dropdownOpen.value = false
|
||||
}
|
||||
|
||||
function goToProfile() {
|
||||
router.push('/parent/profile')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
eventBus.on('open-login', open)
|
||||
})
|
||||
@@ -71,7 +77,7 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="login-button-root" style="position: relative">
|
||||
<div style="position: relative">
|
||||
<button v-if="!isParentAuthenticated" @click="open" aria-label="Parent login" class="login-btn">
|
||||
Parent
|
||||
</button>
|
||||
@@ -99,6 +105,9 @@ onUnmounted(() => {
|
||||
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>
|
||||
@@ -135,11 +144,6 @@ onUnmounted(() => {
|
||||
<style>
|
||||
/* modal */
|
||||
|
||||
.modal h3 {
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.pin-input {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.6rem;
|
||||
@@ -151,16 +155,6 @@ onUnmounted(() => {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: center;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.login-button-root {
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
@@ -183,4 +177,14 @@ onUnmounted(() => {
|
||||
.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>
|
||||
|
||||
28
web/vue-app/src/components/shared/MessageBlock.vue
Normal file
28
web/vue-app/src/components/shared/MessageBlock.vue
Normal 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>
|
||||
@@ -139,34 +139,55 @@ function onAddImage({ id, file }: { id: string; file: File }) {
|
||||
<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">
|
||||
<label>
|
||||
Task Name
|
||||
<input ref="nameInput" v-model="name" type="text" required maxlength="64" />
|
||||
</label>
|
||||
<label>
|
||||
Task Points
|
||||
<input v-model.number="points" type="number" min="1" max="100" required />
|
||||
</label>
|
||||
<label>
|
||||
Task Type
|
||||
<div class="good-bad-toggle">
|
||||
<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 class="form-group image-picker-group">
|
||||
<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"
|
||||
|
||||
@@ -7,12 +7,16 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TaskList
|
||||
<ItemList
|
||||
v-else
|
||||
ref="taskListRef"
|
||||
:deletable="true"
|
||||
@edit-task="(taskId) => $router.push({ name: 'EditTask', params: { id: taskId } })"
|
||||
@delete-task="confirmDeleteTask"
|
||||
fetchUrl="/api/task/list"
|
||||
itemKey="tasks"
|
||||
:itemFields="['id', 'name', 'points', 'is_good', 'image_id']"
|
||||
imageField="image_id"
|
||||
selectable
|
||||
deletable
|
||||
@edit="(taskId) => $router.push({ name: 'EditTask', params: { id: taskId } })"
|
||||
@delete="confirmDeleteTask"
|
||||
@loading-complete="(count) => (taskCountRef = count)"
|
||||
/>
|
||||
|
||||
@@ -40,6 +44,7 @@
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import TaskList from './TaskList.vue'
|
||||
import ItemList from '../shared/ItemList.vue'
|
||||
|
||||
const $router = useRouter()
|
||||
|
||||
|
||||
@@ -203,7 +203,7 @@ function updateLocalImage(url: string, file: File) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="picker">
|
||||
<div class="image-scroll">
|
||||
<div v-if="loadingImages" class="loading-images">Loading images...</div>
|
||||
<div v-else class="image-list">
|
||||
@@ -268,6 +268,13 @@ function updateLocalImage(url: string, file: File) {
|
||||
</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;
|
||||
|
||||
@@ -152,6 +152,11 @@ const routes = [
|
||||
component: NotificationView,
|
||||
props: false,
|
||||
},
|
||||
{
|
||||
path: 'profile',
|
||||
name: 'UserProfile',
|
||||
component: () => import('@/components/profile/UserProfile.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user