round 3
This commit is contained in:
@@ -41,83 +41,98 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="child" class="detail-card">
|
||||
<h1>{{ child.name }}</h1>
|
||||
<div v-if="child" class="detail-card-horizontal">
|
||||
<img v-if="imageUrl" :src="imageUrl" alt="Child Image" class="child-image" />
|
||||
<div class="info">
|
||||
<div class="info-item">
|
||||
<span class="label">Age:</span>
|
||||
<span class="value">{{ child.age }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">Points:</span>
|
||||
<span class="value">{{ child.points ?? '—' }}</span>
|
||||
</div>
|
||||
<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 {
|
||||
.detail-card-horizontal {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.18);
|
||||
padding: 1.2rem 1rem;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.13);
|
||||
padding: 0.7rem 1rem;
|
||||
max-width: 420px;
|
||||
width: 100%;
|
||||
min-height: 64px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.detail-card h1 {
|
||||
font-size: 1.3rem;
|
||||
color: #333;
|
||||
margin-bottom: 1rem;
|
||||
text-align: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.child-image {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
object-fit: cover;
|
||||
border-radius: 50%;
|
||||
display: block;
|
||||
margin: 0 auto 0.7rem auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.info {
|
||||
.main-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
justify-content: center;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
.child-name {
|
||||
font-size: 1.08rem;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.child-age {
|
||||
font-size: 0.97rem;
|
||||
color: #666;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.points {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0.7rem;
|
||||
background: #f5f5f5;
|
||||
border-radius: 7px;
|
||||
font-size: 0.98rem;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
min-width: 54px;
|
||||
margin-left: 0.7rem;
|
||||
}
|
||||
|
||||
.points .label {
|
||||
font-size: 0.85rem;
|
||||
color: #888;
|
||||
margin-bottom: 1px;
|
||||
}
|
||||
|
||||
.points .value {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
/* Even more compact on small screens */
|
||||
@media (max-width: 480px) {
|
||||
.detail-card {
|
||||
padding: 0.7rem 0.4rem;
|
||||
.detail-card-horizontal {
|
||||
padding: 0.5rem 0.4rem;
|
||||
max-width: 98vw;
|
||||
}
|
||||
.detail-card h1 {
|
||||
font-size: 1.05rem;
|
||||
margin-bottom: 0.7rem;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.child-image {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin-bottom: 0.5rem;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
}
|
||||
.info-item {
|
||||
padding: 0.38rem 0.5rem;
|
||||
font-size: 0.93rem;
|
||||
.points {
|
||||
min-width: 38px;
|
||||
margin-left: 0.3rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
247
web/vue-app/src/components/child/ChildEditView.vue
Normal file
247
web/vue-app/src/components/child/ChildEditView.vue
Normal file
@@ -0,0 +1,247 @@
|
||||
<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="form">
|
||||
<div class="form-group">
|
||||
<label for="child-name">Name</label>
|
||||
<input id="child-name" ref="nameInput" v-model="name" required maxlength="64" />
|
||||
</div>
|
||||
<div class="form-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">
|
||||
<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 cancel" @click="onCancel" :disabled="loading">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="btn save" :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 '../ImagePicker.vue'
|
||||
|
||||
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: 'ChildrenListView' })
|
||||
} catch (err) {
|
||||
alert('Failed to save child.')
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
function onCancel() {
|
||||
router.back()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.child-edit-view {
|
||||
max-width: 400px;
|
||||
margin: 2rem auto;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 24px #667eea22;
|
||||
padding: 2rem 2.2rem 1.5rem 2.2rem;
|
||||
}
|
||||
h2 {
|
||||
margin-bottom: 1.2rem;
|
||||
font-size: 1.15rem;
|
||||
color: #667eea;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
}
|
||||
.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: #333;
|
||||
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 #e6e6e6;
|
||||
font-size: 1rem;
|
||||
background: #fafbff;
|
||||
color: #222;
|
||||
transition: border 0.2s;
|
||||
}
|
||||
input:focus {
|
||||
outline: none;
|
||||
border: 1.5px solid #667eea;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.7rem;
|
||||
justify-content: center;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.btn {
|
||||
padding: 0.5rem 1.1rem;
|
||||
border-radius: 8px;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
transition: background 0.18s;
|
||||
}
|
||||
.btn.cancel {
|
||||
background: #f3f3f3;
|
||||
color: #666;
|
||||
}
|
||||
.btn.save {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
}
|
||||
.btn.save:hover {
|
||||
background: #5a67d8;
|
||||
}
|
||||
.form-group.image-picker-group {
|
||||
display: block;
|
||||
text-align: left;
|
||||
}
|
||||
.error {
|
||||
color: #e53e3e;
|
||||
margin-top: 0.7rem;
|
||||
text-align: center;
|
||||
}
|
||||
.loading-message {
|
||||
text-align: center;
|
||||
color: #666;
|
||||
margin-bottom: 1.2rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,37 +1,161 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import ChildDetailCard from './ChildDetailCard.vue'
|
||||
import ChildTaskList from '../task/ChildTaskList.vue'
|
||||
import ChildRewardList from '../reward/ChildRewardList.vue'
|
||||
|
||||
interface Child {
|
||||
id: string | number
|
||||
name: string
|
||||
age: number
|
||||
points?: number
|
||||
}
|
||||
import { eventBus } from '@/common/eventBus'
|
||||
import type {
|
||||
Child,
|
||||
Event,
|
||||
Task,
|
||||
Reward,
|
||||
TaskUpdateEventPayload,
|
||||
RewardUpdateEventPayload,
|
||||
ChildUpdateEventPayload,
|
||||
ChildDeleteEventPayload,
|
||||
TaskCreatedEventPayload,
|
||||
TaskDeletedEventPayload,
|
||||
TaskEditedEventPayload,
|
||||
RewardCreatedEventPayload,
|
||||
RewardDeletedEventPayload,
|
||||
RewardEditedEventPayload,
|
||||
} 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)
|
||||
|
||||
onMounted(async () => {
|
||||
function handlePointsUpdate(event: Event) {
|
||||
const payload = event.payload as TaskUpdateEventPayload | RewardUpdateEventPayload
|
||||
if (child.value && payload.child_id == child.value.id) {
|
||||
child.value.points = payload.points
|
||||
}
|
||||
}
|
||||
|
||||
function handleServerChange(event: Event) {
|
||||
const payload = event.payload as
|
||||
| TaskUpdateEventPayload
|
||||
| RewardUpdateEventPayload
|
||||
| ChildUpdateEventPayload
|
||||
if (child.value && payload.child_id == child.value.id) {
|
||||
fetchChildData(child.value.id)
|
||||
}
|
||||
}
|
||||
|
||||
function handleChildDeletion(event: Event) {
|
||||
const payload = event.payload as ChildDeleteEventPayload
|
||||
if (child.value && payload.child_id == child.value.id) {
|
||||
// Navigate away back to children list
|
||||
router.push({ name: 'ChildrenListView' })
|
||||
}
|
||||
}
|
||||
|
||||
function handleTaskChanged(event: Event) {
|
||||
const payload = event.payload as
|
||||
| TaskCreatedEventPayload
|
||||
| TaskDeletedEventPayload
|
||||
| TaskEditedEventPayload
|
||||
if (child.value) {
|
||||
const task_id = payload.task_id
|
||||
if (tasks.value.includes(task_id)) {
|
||||
fetchChildData(child.value.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleRewardChanged(event: Event) {
|
||||
const payload = event.payload as
|
||||
| RewardCreatedEventPayload
|
||||
| RewardDeletedEventPayload
|
||||
| RewardEditedEventPayload
|
||||
if (child.value) {
|
||||
const reward_id = payload.reward_id
|
||||
if (rewards.value.includes(reward_id)) {
|
||||
fetchChildData(child.value.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleTriggerTask = (task: Task) => {
|
||||
if ('speechSynthesis' in window && task.name) {
|
||||
const utter = new window.SpeechSynthesisUtterance(task.name)
|
||||
window.speechSynthesis.speak(utter)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTriggerReward = (reward: Reward, redeemable: boolean) => {
|
||||
if ('speechSynthesis' in window && reward.name) {
|
||||
console.log('Handle trigger reward:', reward, redeemable)
|
||||
const utterString =
|
||||
reward.name + (redeemable ? '' : `, You still need ${reward.points_needed} points.`)
|
||||
const utter = new window.SpeechSynthesisUtterance(utterString)
|
||||
window.speechSynthesis.speak(utter)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchChildData(id: string | number) {
|
||||
loading.value = true
|
||||
try {
|
||||
const resp = await fetch(`/api/child/${route.params.id}`)
|
||||
const resp = await fetch(`/api/child/${id}`)
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
const data = await resp.json()
|
||||
child.value = data.children ? data.children : data
|
||||
tasks.value = data.tasks || []
|
||||
rewards.value = data.rewards || []
|
||||
error.value = null
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to fetch child'
|
||||
console.error(err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
eventBus.on('task_update', handlePointsUpdate)
|
||||
eventBus.on('reward_update', handlePointsUpdate)
|
||||
eventBus.on('task_set', handleServerChange)
|
||||
eventBus.on('reward_set', handleServerChange)
|
||||
eventBus.on('child_update', handleServerChange)
|
||||
eventBus.on('child_delete', handleChildDeletion)
|
||||
eventBus.on('task_created', handleTaskChanged)
|
||||
eventBus.on('task_deleted', handleTaskChanged)
|
||||
eventBus.on('task_edited', handleTaskChanged)
|
||||
eventBus.on('reward_created', handleRewardChanged)
|
||||
eventBus.on('reward_deleted', handleRewardChanged)
|
||||
eventBus.on('reward_edited', handleRewardChanged)
|
||||
|
||||
if (route.params.id) {
|
||||
const idParam = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id
|
||||
if (idParam !== undefined) {
|
||||
fetchChildData(idParam)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error in onMounted:', err)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
eventBus.off('task_update', handlePointsUpdate)
|
||||
eventBus.off('reward_update', handlePointsUpdate)
|
||||
eventBus.off('task_set', handleServerChange)
|
||||
eventBus.off('reward_set', handleServerChange)
|
||||
eventBus.off('child_update', handleServerChange)
|
||||
eventBus.off('child_delete', handleChildDeletion)
|
||||
eventBus.off('task_created', handleTaskChanged)
|
||||
eventBus.off('task_deleted', handleTaskChanged)
|
||||
eventBus.off('task_edited', handleTaskChanged)
|
||||
eventBus.off('reward_created', handleRewardChanged)
|
||||
eventBus.off('reward_deleted', handleRewardChanged)
|
||||
eventBus.off('reward_edited', handleRewardChanged)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -44,26 +168,33 @@ onMounted(async () => {
|
||||
<div class="main">
|
||||
<ChildDetailCard :child="child" />
|
||||
<ChildTaskList
|
||||
title="Chores"
|
||||
:task-ids="tasks"
|
||||
:child-id="child ? child.id : null"
|
||||
:is-parent-authenticated="false"
|
||||
:filter-type="1"
|
||||
@trigger-task="handleTriggerTask"
|
||||
/>
|
||||
<ChildTaskList
|
||||
title="Bad Habits"
|
||||
:task-ids="tasks"
|
||||
:child-id="child ? child.id : null"
|
||||
:is-parent-authenticated="false"
|
||||
:filter-type="2"
|
||||
@trigger-task="handleTriggerTask"
|
||||
/>
|
||||
<ChildRewardList
|
||||
:child-id="child ? child.id : null"
|
||||
:child-points="child?.points ?? 0"
|
||||
:is-parent-authenticated="false"
|
||||
@trigger-reward="handleTriggerReward"
|
||||
@points-updated="
|
||||
({ id, points }) => {
|
||||
if (child && child.id === id) child.points = points
|
||||
}
|
||||
"
|
||||
/>
|
||||
<!-- removed placeholder -->
|
||||
</div>
|
||||
<!-- Remove this aside block:
|
||||
<aside class="side">
|
||||
<div class="placeholder">Additional components go here</div>
|
||||
</aside>
|
||||
-->
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -74,7 +205,7 @@ onMounted(async () => {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
min-height: 100vh;
|
||||
padding: 2rem;
|
||||
padding: 0.5rem;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@@ -1,45 +1,188 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ref, onMounted, computed, onUnmounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { isParentAuthenticated } from '../../stores/auth'
|
||||
import ChildDetailCard from './ChildDetailCard.vue'
|
||||
import ChildTaskList from '../task/ChildTaskList.vue'
|
||||
import ChildRewardList from '../reward/ChildRewardList.vue'
|
||||
import AssignTaskButton from '../AssignTaskButton.vue'
|
||||
|
||||
interface Child {
|
||||
id: string | number
|
||||
name: string
|
||||
age: number
|
||||
points?: number
|
||||
}
|
||||
import { eventBus } from '@/common/eventBus'
|
||||
import type {
|
||||
Task,
|
||||
Child,
|
||||
Event,
|
||||
Reward,
|
||||
TaskUpdateEventPayload,
|
||||
RewardUpdateEventPayload,
|
||||
ChildUpdateEventPayload,
|
||||
ChildDeleteEventPayload,
|
||||
} from '@/common/models'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const child = ref<Child | null>(null)
|
||||
const tasks = ref<string[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
const rewardListRef = ref()
|
||||
const showConfirm = ref(false)
|
||||
const selectedTask = ref<Task | null>(null)
|
||||
const showRewardConfirm = ref(false)
|
||||
const selectedReward = ref<Reward | null>(null)
|
||||
|
||||
onMounted(async () => {
|
||||
function handlePointsUpdate(event: Event) {
|
||||
const payload = event.payload as TaskUpdateEventPayload | RewardUpdateEventPayload
|
||||
if (child.value && payload.child_id == child.value.id) {
|
||||
child.value.points = payload.points
|
||||
}
|
||||
}
|
||||
|
||||
function handleServerChange(event: Event) {
|
||||
const payload = event.payload as
|
||||
| TaskUpdateEventPayload
|
||||
| RewardUpdateEventPayload
|
||||
| ChildUpdateEventPayload
|
||||
if (child.value && payload.child_id == child.value.id) {
|
||||
fetchChildData(child.value.id)
|
||||
}
|
||||
}
|
||||
|
||||
function handleChildDeletion(event: Event) {
|
||||
const payload = event.payload as ChildDeleteEventPayload
|
||||
if (child.value && payload.child_id == child.value.id) {
|
||||
// Navigate away back to children list
|
||||
router.push({ name: 'ChildrenListView' })
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchChildData(id: string | number) {
|
||||
loading.value = true
|
||||
try {
|
||||
const resp = await fetch(`/api/child/${route.params.id}`)
|
||||
const resp = await fetch(`/api/child/${id}`)
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
const data = await resp.json()
|
||||
child.value = data.children ? data.children : data
|
||||
tasks.value = data.tasks || []
|
||||
error.value = null
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to fetch child'
|
||||
console.error(err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
eventBus.on('task_update', handlePointsUpdate)
|
||||
eventBus.on('reward_update', handlePointsUpdate)
|
||||
eventBus.on('task_set', handleServerChange)
|
||||
eventBus.on('reward_set', handleServerChange)
|
||||
eventBus.on('child_update', handleServerChange)
|
||||
eventBus.on('child_delete', handleChildDeletion)
|
||||
|
||||
if (route.params.id) {
|
||||
const idParam = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id
|
||||
if (idParam !== undefined) {
|
||||
fetchChildData(idParam)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error in onMounted:', err)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
eventBus.off('task_update', handlePointsUpdate)
|
||||
eventBus.off('reward_update', handlePointsUpdate)
|
||||
eventBus.off('task_set', handleServerChange)
|
||||
eventBus.off('reward_set', handleServerChange)
|
||||
eventBus.off('child_update', handleServerChange)
|
||||
eventBus.off('child_delete', handleChildDeletion)
|
||||
})
|
||||
|
||||
const refreshRewards = () => {
|
||||
rewardListRef.value?.refresh()
|
||||
}
|
||||
|
||||
const handleTriggerTask = (task: Task) => {
|
||||
selectedTask.value = task
|
||||
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()
|
||||
console.log('Trigger task response data:', child.value.id, data.id)
|
||||
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 handleTriggerReward = (reward: Reward, redeemable: boolean) => {
|
||||
console.log('Handle trigger reward:', reward, redeemable)
|
||||
if (!redeemable) 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 } })
|
||||
}
|
||||
}
|
||||
|
||||
const handleTaskPointsUpdated = ({ id, points }: { id: string | number; points: number }) => {
|
||||
if (child.value && child.value.id === id) child.value.points = points
|
||||
refreshRewards()
|
||||
}
|
||||
const handleRewardPointsUpdated = ({ id, points }: { id: string | number; points: number }) => {
|
||||
if (child.value && child.value.id === id) child.value.points = points
|
||||
}
|
||||
|
||||
const childId = computed(() => child.value?.id ?? null)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -51,31 +194,118 @@ const refreshRewards = () => {
|
||||
<div class="main">
|
||||
<ChildDetailCard :child="child" />
|
||||
<ChildTaskList
|
||||
title="Chores"
|
||||
:task-ids="tasks"
|
||||
:child-id="child ? child.id : null"
|
||||
:child-id="childId"
|
||||
:is-parent-authenticated="isParentAuthenticated"
|
||||
@points-updated="
|
||||
({ id, points }) => {
|
||||
if (child && child.id === id) child.points = points
|
||||
refreshRewards()
|
||||
}
|
||||
"
|
||||
:filter-type="1"
|
||||
@points-updated="handleTaskPointsUpdated"
|
||||
@trigger-task="handleTriggerTask"
|
||||
/>
|
||||
<ChildTaskList
|
||||
title="Bad Habits"
|
||||
:task-ids="tasks"
|
||||
:child-id="childId"
|
||||
:is-parent-authenticated="isParentAuthenticated"
|
||||
:filter-type="2"
|
||||
@points-updated="handleTaskPointsUpdated"
|
||||
@trigger-task="handleTriggerTask"
|
||||
/>
|
||||
<ChildRewardList
|
||||
ref="rewardListRef"
|
||||
:child-id="child ? child.id : null"
|
||||
:is-parent-authenticated="isParentAuthenticated"
|
||||
@points-updated="
|
||||
({ id, points }) => {
|
||||
if (child && child.id === id) child.points = points
|
||||
refreshRewards()
|
||||
}
|
||||
"
|
||||
:child-id="childId"
|
||||
:child-points="child?.points ?? 0"
|
||||
:is-parent-authenticated="false"
|
||||
@points-updated="handleRewardPointsUpdated"
|
||||
@trigger-reward="handleTriggerReward"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Place the AssignTaskButton here, outside .main but inside .container -->
|
||||
<AssignTaskButton :child-id="child ? child.id : null" />
|
||||
<div class="assign-buttons">
|
||||
<button v-if="child" class="assign-task-btn" @click="goToAssignTasks">Assign Tasks</button>
|
||||
<button v-if="child" class="assign-bad-btn" @click="goToAssignBadHabits">
|
||||
Assign Bad Habits
|
||||
</button>
|
||||
<button v-if="child" class="assign-reward-btn" @click="goToAssignRewards">
|
||||
Assign Rewards
|
||||
</button>
|
||||
</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 }} pts
|
||||
</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">Yes</button>
|
||||
<button
|
||||
@click="
|
||||
() => {
|
||||
showConfirm = false
|
||||
selectedTask = null
|
||||
}
|
||||
"
|
||||
>
|
||||
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_id"
|
||||
:src="selectedReward.image_id"
|
||||
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 + ' pts needed'
|
||||
}}
|
||||
</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">Yes</button>
|
||||
<button
|
||||
@click="
|
||||
() => {
|
||||
showRewardConfirm = false
|
||||
selectedReward = null
|
||||
}
|
||||
"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -89,16 +319,6 @@ const refreshRewards = () => {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.back-btn {
|
||||
background: white;
|
||||
border: 0;
|
||||
padding: 0.6rem 1rem;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
margin-bottom: 1.5rem;
|
||||
color: #667eea;
|
||||
font-weight: 600;
|
||||
}
|
||||
.loading,
|
||||
.error {
|
||||
color: white;
|
||||
@@ -118,9 +338,6 @@ const refreshRewards = () => {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
/* Remove grid styles */
|
||||
/* grid-template-columns: 1fr 320px; */
|
||||
/* gap: 1.5rem; */
|
||||
}
|
||||
.main {
|
||||
display: flex;
|
||||
@@ -130,22 +347,84 @@ const refreshRewards = () => {
|
||||
width: 100%;
|
||||
max-width: 600px; /* or whatever width fits your content best */
|
||||
}
|
||||
.side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
.placeholder {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: white;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
min-height: 120px;
|
||||
|
||||
/* Modal styles */
|
||||
.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: #fff;
|
||||
color: #222;
|
||||
padding: 1.5rem 2rem;
|
||||
border-radius: 12px;
|
||||
min-width: 240px;
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.2);
|
||||
text-align: center;
|
||||
}
|
||||
.task-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.task-image {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
background: #eee;
|
||||
}
|
||||
.task-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.task-name {
|
||||
font-weight: 600;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.task-points,
|
||||
.task-points.good,
|
||||
.task-points.bad {
|
||||
font-weight: 600;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.task-points.good {
|
||||
color: #38c172;
|
||||
}
|
||||
.task-points.bad {
|
||||
color: #ef4444;
|
||||
}
|
||||
.actions {
|
||||
margin-top: 1.2rem;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
}
|
||||
.actions button {
|
||||
padding: 0.5rem 1.2rem;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.actions button:first-child {
|
||||
background: #667eea;
|
||||
color: #fff;
|
||||
}
|
||||
.actions button:last-child {
|
||||
background: #f3f3f3;
|
||||
color: #666;
|
||||
}
|
||||
.actions button:last-child:hover {
|
||||
background: #e2e8f0;
|
||||
}
|
||||
|
||||
/* Mobile adjustments */
|
||||
@media (max-width: 900px) {
|
||||
@@ -158,17 +437,83 @@ const refreshRewards = () => {
|
||||
.container {
|
||||
padding: 1rem;
|
||||
}
|
||||
.back-btn {
|
||||
padding: 0.45rem 0.75rem;
|
||||
font-size: 0.95rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.main {
|
||||
gap: 1rem;
|
||||
}
|
||||
.placeholder {
|
||||
padding: 0.75rem;
|
||||
min-height: 80px;
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-message {
|
||||
font-size: 1.08rem;
|
||||
color: #444;
|
||||
font-weight: 500;
|
||||
}
|
||||
.dialog-message .child-name {
|
||||
color: #667eea;
|
||||
font-weight: 700;
|
||||
margin-left: 2px;
|
||||
}
|
||||
.reward-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.reward-image {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
background: #eee;
|
||||
}
|
||||
.reward-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.reward-name {
|
||||
font-weight: 600;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.reward-points {
|
||||
color: #667eea;
|
||||
font-weight: 500;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.assign-buttons {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
.assign-task-btn,
|
||||
.assign-bad-btn,
|
||||
.assign-reward-btn {
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 0.7rem 1.5rem;
|
||||
font-size: 1.1rem;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.08);
|
||||
transition: background 0.18s;
|
||||
color: #fff;
|
||||
background: #667eea;
|
||||
}
|
||||
.assign-task-btn:hover,
|
||||
.assign-bad-btn:hover,
|
||||
.assign-reward-btn:hover {
|
||||
background: #5a67d8;
|
||||
}
|
||||
.assign-bad-btn {
|
||||
background: #ef4444;
|
||||
}
|
||||
.assign-bad-btn:hover {
|
||||
background: #dc2626;
|
||||
}
|
||||
.assign-reward-btn {
|
||||
background: #38c172;
|
||||
}
|
||||
.assign-reward-btn:hover {
|
||||
background: #2f855a;
|
||||
}
|
||||
</style>
|
||||
|
||||
94
web/vue-app/src/components/child/RewardAssignView.vue
Normal file
94
web/vue-app/src/components/child/RewardAssignView.vue
Normal file
@@ -0,0 +1,94 @@
|
||||
<template>
|
||||
<div class="reward-assign-view">
|
||||
<h2>Assign Rewards</h2>
|
||||
<div class="reward-list-scroll">
|
||||
<RewardList ref="rewardListRef" :child-id="childId" :selectable="true" />
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn cancel" @click="onCancel">Cancel</button>
|
||||
<button class="btn submit" @click="onSubmit">Submit</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import RewardList from '../reward/RewardList.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const childId = route.params.id
|
||||
|
||||
const rewardListRef = ref()
|
||||
|
||||
async function onSubmit() {
|
||||
const selectedIds = rewardListRef.value?.selectedRewards ?? []
|
||||
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;
|
||||
}
|
||||
h2 {
|
||||
font-size: 1.15rem;
|
||||
color: #ffffff;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
margin: 0.2rem;
|
||||
}
|
||||
.reward-list-scroll {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: flex-end;
|
||||
margin-top: 0;
|
||||
}
|
||||
.btn {
|
||||
padding: 0.5rem 1.2rem;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.btn.cancel {
|
||||
background: #f3f3f3;
|
||||
color: #666;
|
||||
}
|
||||
.btn.submit {
|
||||
background: #667eea;
|
||||
color: #fff;
|
||||
}
|
||||
.btn.submit:hover {
|
||||
background: #5a67d8;
|
||||
}
|
||||
</style>
|
||||
105
web/vue-app/src/components/child/TaskAssignView.vue
Normal file
105
web/vue-app/src/components/child/TaskAssignView.vue
Normal file
@@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<div class="task-assign-view">
|
||||
<h2>Assign Tasks</h2>
|
||||
<div class="task-list-scroll">
|
||||
<TaskList
|
||||
ref="taskListRef"
|
||||
:child-id="childId"
|
||||
:selectable="true"
|
||||
:type-filter="typeFilter"
|
||||
/>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn cancel" @click="onCancel">Cancel</button>
|
||||
<button class="btn submit" @click="onSubmit">Submit</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import TaskList from '../task/TaskList.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const childId = route.params.id
|
||||
|
||||
const taskListRef = ref()
|
||||
|
||||
const typeFilter = computed(() => {
|
||||
if (route.params.type === 'good') return 'good'
|
||||
if (route.params.type === 'bad') return 'bad'
|
||||
return 'all'
|
||||
})
|
||||
|
||||
async function onSubmit() {
|
||||
const selectedIds = taskListRef.value?.selectedTasks ?? []
|
||||
try {
|
||||
const resp = await fetch(`/api/child/${childId}/set-tasks`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ 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;
|
||||
}
|
||||
h2 {
|
||||
font-size: 1.15rem;
|
||||
color: #ffffff;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
margin: 0.2rem;
|
||||
}
|
||||
.task-list-scroll {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: flex-end;
|
||||
margin-top: 0;
|
||||
}
|
||||
.btn {
|
||||
padding: 0.5rem 1.2rem;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.btn.cancel {
|
||||
background: #f3f3f3;
|
||||
color: #666;
|
||||
}
|
||||
.btn.submit {
|
||||
background: #667eea;
|
||||
color: #fff;
|
||||
}
|
||||
.btn.submit:hover {
|
||||
background: #5a67d8;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user