Refactor forms to use EntityEditForm component; enhance styles and improve structure
Some checks failed
Gitea Actions Demo / build-and-push (push) Failing after 6s

This commit is contained in:
2026-01-23 19:29:27 -05:00
parent 63769fbe32
commit 74d6f5819c
7 changed files with 416 additions and 310 deletions

View File

@@ -2,7 +2,7 @@
.btn { .btn {
font-weight: 600; font-weight: 600;
border: none; border: none;
border-radius: 8px; border-radius: 12px;
padding: 0.7rem 1.5rem; padding: 0.7rem 1.5rem;
font-size: 1.1rem; font-size: 1.1rem;
cursor: pointer; cursor: pointer;

View File

@@ -1,56 +1,35 @@
<template> <template>
<div class="child-edit-view"> <EntityEditForm
<h2>{{ isEdit ? 'Edit Child' : 'Create Child' }}</h2> entityLabel="Child"
<div v-if="loading" class="loading-message">Loading child...</div> :fields="fields"
<form v-else @submit.prevent="submit" class="child-edit-form"> :initialData="initialData"
<div class="group"> :isEdit="isEdit"
<label for="child-name">Name</label> :loading="loading"
<input type="text" id="child-name" ref="nameInput" v-model="name" required maxlength="64" /> :error="error"
</div> @submit="handleSubmit"
<div class="group"> @cancel="handleCancel"
<label for="child-age">Age</label> @add-image="handleAddImage"
<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> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, computed, nextTick } from 'vue' import { ref, onMounted, computed, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import ImagePicker from '@/components/utils/ImagePicker.vue' import EntityEditForm from '../shared/EntityEditForm.vue'
import '@/assets/edit-forms.css'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
// Accept id as a prop for edit mode
const props = defineProps<{ id?: string }>() const props = defineProps<{ id?: string }>()
const isEdit = computed(() => !!props.id) const isEdit = computed(() => !!props.id)
const name = ref('')
const age = ref<number | null>(null) const fields = [
const selectedImageId = ref<string | null>(null) { name: 'name', label: 'Name', type: 'text', required: true, maxlength: 64 },
{ name: 'age', label: 'Age', type: 'number', required: true, min: 0, max: 120 },
{ name: 'image_id', label: 'Image', type: 'image', imageType: 1 },
]
const initialData = ref({ name: '', age: null, image_id: null })
const localImageFile = ref<File | null>(null) const localImageFile = ref<File | null>(null)
const nameInput = ref<HTMLInputElement | null>(null)
const loading = ref(false) const loading = ref(false)
const error = ref<string | null>(null) const error = ref<string | null>(null)
@@ -61,36 +40,34 @@ onMounted(async () => {
const resp = await fetch(`/api/child/${props.id}`) const resp = await fetch(`/api/child/${props.id}`)
if (!resp.ok) throw new Error('Failed to load child') if (!resp.ok) throw new Error('Failed to load child')
const data = await resp.json() const data = await resp.json()
name.value = data.name ?? '' initialData.value = {
age.value = Number(data.age) ?? null name: data.name ?? '',
selectedImageId.value = data.image_id ?? null age: Number(data.age) ?? null,
image_id: data.image_id ?? null,
}
} catch (e) { } catch (e) {
error.value = 'Could not load child.' error.value = 'Could not load child.'
} finally { } finally {
loading.value = false loading.value = false
await nextTick() await nextTick()
nameInput.value?.focus()
} }
} else {
await nextTick()
nameInput.value?.focus()
} }
}) })
function onAddImage({ id, file }: { id: string; file: File }) { function handleAddImage({ id, file }: { id: string; file: File }) {
if (id === 'local-upload') { if (id === 'local-upload') {
localImageFile.value = file localImageFile.value = file
} }
} }
const submit = async () => { async function handleSubmit(form: any) {
let imageId = selectedImageId.value let imageId = form.image_id
error.value = null error.value = null
if (!name.value.trim()) { if (!form.name.trim()) {
error.value = 'Child name is required.' error.value = 'Child name is required.'
return return
} }
if (age.value === null || age.value < 0) { if (form.age === null || form.age < 0) {
error.value = 'Age must be a non-negative number.' error.value = 'Age must be a non-negative number.'
return return
} }
@@ -111,7 +88,7 @@ const submit = async () => {
const data = await resp.json() const data = await resp.json()
imageId = data.id imageId = data.id
} catch (err) { } catch (err) {
alert('Failed to upload image.') error.value = 'Failed to upload image.'
loading.value = false loading.value = false
return return
} }
@@ -125,8 +102,8 @@ const submit = async () => {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
name: name.value, name: form.name,
age: age.value, age: form.age,
image_id: imageId, image_id: imageId,
}), }),
}) })
@@ -135,8 +112,8 @@ const submit = async () => {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
name: name.value, name: form.name,
age: age.value, age: form.age,
image_id: imageId, image_id: imageId,
}), }),
}) })
@@ -144,14 +121,12 @@ const submit = async () => {
if (!resp.ok) throw new Error('Failed to save child') if (!resp.ok) throw new Error('Failed to save child')
await router.push({ name: 'ParentChildrenListView' }) await router.push({ name: 'ParentChildrenListView' })
} catch (err) { } catch (err) {
alert('Failed to save child.') error.value = 'Failed to save child.'
} }
loading.value = false loading.value = false
} }
function onCancel() { function handleCancel() {
router.back() router.back()
} }
</script> </script>
<style scoped></style>

View File

@@ -1,6 +1,10 @@
<template> <template>
<div class="notification-view"> <div class="notification-view">
<MessageBlock v-if="notificationListCountRef === 0" message="No new notifications">
</MessageBlock>
<ItemList <ItemList
v-else
:fetchUrl="`/api/pending-rewards`" :fetchUrl="`/api/pending-rewards`"
itemKey="rewards" itemKey="rewards"
:itemFields="PENDING_REWARD_FIELDS" :itemFields="PENDING_REWARD_FIELDS"
@@ -29,6 +33,7 @@
import { ref } from 'vue' import { ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import ItemList from '../shared/ItemList.vue' import ItemList from '../shared/ItemList.vue'
import MessageBlock from '../shared/MessageBlock.vue'
import type { PendingReward } from '@/common/models' import type { PendingReward } from '@/common/models'
import { PENDING_REWARD_FIELDS } from '@/common/models' import { PENDING_REWARD_FIELDS } from '@/common/models'

View File

@@ -1,67 +1,46 @@
<template> <template>
<div class="reward-edit-view"> <EntityEditForm
<h2>{{ isEdit ? 'Edit Reward' : 'Create Reward' }}</h2> entityLabel="Reward"
<div v-if="loading" class="loading-message">Loading reward...</div> :fields="fields"
<form v-else @submit.prevent="submit" class="reward-form"> :initialData="initialData"
<div class="group"> :isEdit="isEdit"
<label> :loading="loading"
Reward Name :error="error"
<input ref="nameInput" v-model="name" type="text" required maxlength="64" /> @submit="handleSubmit"
</label> @cancel="handleCancel"
</div> @add-image="handleAddImage"
<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> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, computed, nextTick } from 'vue' import { ref, onMounted, computed, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import ImagePicker from '@/components/utils/ImagePicker.vue' import EntityEditForm from '../shared/EntityEditForm.vue'
import '@/assets/edit-forms.css'
const props = defineProps<{ id?: string }>() const props = defineProps<{ id?: string }>()
const route = useRoute()
const router = useRouter() const router = useRouter()
const isEdit = computed(() => !!props.id) const isEdit = computed(() => !!props.id)
const name = ref('') const fields: {
const description = ref('') name: string
const cost = ref(1) label: string
const selectedImageId = ref<string | null>(null) type: 'text' | 'number' | 'image'
required?: boolean
maxlength?: number
min?: number
max?: number
imageType?: number
}[] = [
{ name: 'name', label: 'Reward Name', type: 'text', required: true, maxlength: 64 },
{ name: 'description', label: 'Description', type: 'text', maxlength: 128 },
{ name: 'cost', label: 'Cost', type: 'number', required: true, min: 1, max: 1000 },
{ name: 'image_id', label: 'Image', type: 'image', imageType: 2 },
]
// removed duplicate defineProps
const initialData = ref({ name: '', description: '', cost: 1, image_id: null })
const localImageFile = ref<File | null>(null) const localImageFile = ref<File | null>(null)
const loading = ref(false) const loading = ref(false)
const error = ref<string | null>(null) const error = ref<string | null>(null)
const nameInput = ref<HTMLInputElement | null>(null)
onMounted(async () => { onMounted(async () => {
if (isEdit.value && props.id) { if (isEdit.value && props.id) {
@@ -70,41 +49,40 @@ onMounted(async () => {
const resp = await fetch(`/api/reward/${props.id}`) const resp = await fetch(`/api/reward/${props.id}`)
if (!resp.ok) throw new Error('Failed to load reward') if (!resp.ok) throw new Error('Failed to load reward')
const data = await resp.json() const data = await resp.json()
name.value = data.name initialData.value = {
description.value = data.description ?? '' name: data.name ?? '',
cost.value = Number(data.cost) || 1 description: data.description ?? '',
selectedImageId.value = data.image_id ?? null cost: Number(data.cost) || 1,
} catch (e) { image_id: data.image_id ?? null,
}
} catch {
error.value = 'Could not load reward.' error.value = 'Could not load reward.'
} finally { } finally {
loading.value = false loading.value = false
await nextTick() await nextTick()
nameInput.value?.focus()
} }
} else {
await nextTick()
nameInput.value?.focus()
} }
}) })
function onAddImage({ id, file }: { id: string; file: File }) { function handleAddImage({ id, file }: { id: string; file: File }) {
if (id === 'local-upload') { if (id === 'local-upload') {
localImageFile.value = file localImageFile.value = file
} }
} }
function handleCancel() { async function handleSubmit(form: {
router.back() name: string
} description: string
cost: number
const submit = async () => { image_id: string | null
let imageId = selectedImageId.value }) {
let imageId = form.image_id
error.value = null error.value = null
if (!name.value.trim()) { if (!form.name.trim()) {
error.value = 'Reward name is required.' error.value = 'Reward name is required.'
return return
} }
if (cost.value < 1) { if (form.cost < 1) {
error.value = 'Cost must be at least 1.' error.value = 'Cost must be at least 1.'
return return
} }
@@ -124,8 +102,8 @@ const submit = async () => {
if (!resp.ok) throw new Error('Image upload failed') if (!resp.ok) throw new Error('Image upload failed')
const data = await resp.json() const data = await resp.json()
imageId = data.id imageId = data.id
} catch (err) { } catch {
alert('Failed to upload image.') error.value = 'Failed to upload image.'
loading.value = false loading.value = false
return return
} }
@@ -139,9 +117,9 @@ const submit = async () => {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
name: name.value, name: form.name,
description: description.value, description: form.description,
cost: cost.value, cost: form.cost,
image_id: imageId, image_id: imageId,
}), }),
}) })
@@ -150,20 +128,24 @@ const submit = async () => {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
name: name.value, name: form.name,
description: description.value, description: form.description,
cost: cost.value, cost: form.cost,
image_id: imageId, image_id: imageId,
}), }),
}) })
} }
if (!resp.ok) throw new Error('Failed to save reward') if (!resp.ok) throw new Error('Failed to save reward')
await router.push({ name: 'RewardView' }) await router.push({ name: 'RewardView' })
} catch (err) { } catch {
alert('Failed to save reward.') error.value = 'Failed to save reward.'
} }
loading.value = false loading.value = false
} }
function handleCancel() {
router.back()
}
</script> </script>
<style scoped></style> <style scoped></style>

View File

@@ -0,0 +1,169 @@
<template>
<div class="entity-edit-view">
<h2>{{ isEdit ? `Edit ${entityLabel}` : `Create ${entityLabel}` }}</h2>
<div v-if="loading" class="loading-message">Loading {{ entityLabel.toLowerCase() }}...</div>
<form v-else @submit.prevent="submit" class="entity-form">
<template v-for="field in fields" :key="field.name">
<div class="group">
<label :for="field.name">
{{ field.label }}
<!-- Custom field slot -->
<slot
:name="`custom-field-${field.name}`"
:modelValue="formData[field.name]"
:update="(val) => (formData[field.name] = val)"
>
<!-- Default rendering if no slot provided -->
<input
v-if="field.type === 'text' || field.type === 'number'"
:id="field.name"
v-model="formData[field.name]"
:type="field.type"
:required="field.required"
:maxlength="field.maxlength"
:min="field.min"
:max="field.max"
/>
<ImagePicker
v-else-if="field.type === 'image'"
:id="field.name"
v-model="formData[field.name]"
:image-type="field.imageType || 1"
@add-image="onAddImage"
/>
</slot>
</label>
</div>
</template>
<div v-if="error" class="error">{{ error }}</div>
<div class="actions">
<button type="button" class="btn btn-secondary" @click="onCancel" :disabled="loading">
Cancel
</button>
<button type="submit" class="btn btn-primary" :disabled="loading">
{{ isEdit ? 'Save' : 'Create' }}
</button>
</div>
</form>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, nextTick, watch } from 'vue'
import ImagePicker from '@/components/utils/ImagePicker.vue'
import { useRouter } from 'vue-router'
import '@/assets/colors.css'
import '@/assets/styles.css'
type Field = {
name: string
label: string
type: 'text' | 'number' | 'image' | 'custom'
required?: boolean
maxlength?: number
min?: number
max?: number
imageType?: number
}
const props = defineProps<{
entityLabel: string
fields: Field[]
initialData?: Record<string, any>
isEdit?: boolean
loading?: boolean
error?: string | null
}>()
const emit = defineEmits(['submit', 'cancel', 'add-image'])
const router = useRouter()
const formData = ref<Record<string, any>>({ ...props.initialData })
watch(
() => props.initialData,
(newVal) => {
if (newVal) {
formData.value = { ...newVal }
}
},
{ immediate: true, deep: true },
)
onMounted(async () => {
await nextTick()
// Optionally focus first input
})
function onAddImage({ id, file }: { id: string; file: File }) {
emit('add-image', { id, file })
}
function onCancel() {
emit('cancel')
router.back()
}
function submit() {
emit('submit', { ...formData.value })
}
</script>
<style scoped>
.entity-edit-view {
max-width: 420px;
margin: 0 auto;
background: var(--edit-view-bg, #fff);
border-radius: 14px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
padding: 2.2rem 2.2rem 1.5rem 2.2rem;
}
.entity-edit-view h2 {
text-align: center;
margin-bottom: 1.5rem;
color: var(--form-heading);
}
.entity-form .group {
margin-bottom: 1.2rem;
}
.entity-form label {
display: block;
font-weight: 600;
color: var(--form-label, #444);
margin-bottom: 0.4rem;
}
.entity-form input[type='text'],
.entity-form input[type='number'] {
width: 100%;
padding: 0.6rem;
border-radius: 7px;
border: 1px solid var(--form-input-border, #e6e6e6);
font-size: 1rem;
background: var(--form-input-bg, #fff);
box-sizing: border-box;
}
.actions {
display: flex;
gap: 3rem;
justify-content: center;
margin-top: 0.5rem;
margin-bottom: 0.4rem;
}
.actions .btn {
padding: 1rem 2.2rem;
font-weight: 700;
font-size: 1.25rem;
min-width: 120px;
}
.error {
color: var(--error-color, #e53e3e);
margin-bottom: 1rem;
font-size: 1rem;
}
.loading-message {
color: var(--loading-color, #888);
margin-bottom: 1.2rem;
font-size: 1rem;
}
</style>

View File

@@ -1,66 +1,122 @@
<style scoped>
.good-bad-toggle {
display: flex;
gap: 0.5rem;
margin-top: 1.2rem;
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;
background: var(--toggle-btn-bg, #f5f5f5);
color: var(--toggle-btn-color, #333);
border-color: var(--toggle-btn-border, #ccc);
}
button.toggle-btn.good-active {
background: var(--toggle-btn-good-bg, #e6ffe6);
color: var(--toggle-btn-good-color, #1a7f37);
box-shadow: 0 2px 8px var(--toggle-btn-good-shadow, #b6f2c2);
transform: translateY(2px) scale(0.97);
border-style: ridge;
border-color: var(--toggle-btn-good-border, #1a7f37);
}
button.toggle-btn.bad-active {
background: var(--toggle-btn-bad-bg, #ffe6e6);
color: var(--toggle-btn-bad-color, #b91c1c);
box-shadow: 0 2px 8px var(--toggle-btn-bad-shadow, #f2b6b6);
transform: translateY(2px) scale(0.97);
border-style: ridge;
border-color: var(--toggle-btn-bad-border, #b91c1c);
}
</style>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, computed, defineEmits, nextTick } from 'vue' import { ref, onMounted, computed, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import ImagePicker from '@/components/utils/ImagePicker.vue' import EntityEditForm from '../shared/EntityEditForm.vue'
import '@/assets/edit-forms.css'
const route = useRoute() const props = defineProps<{ id?: string }>()
const router = useRouter() const router = useRouter()
const isEdit = computed(() => !!route.params.id) const isEdit = computed(() => !!props.id)
const emit = defineEmits<{
(e: 'updated'): void
}>()
// Define props
const props = defineProps<{
id?: string
}>()
const name = ref('') const fields: {
const points = ref(0) name: string
label: string
type: 'text' | 'number' | 'image' | 'custom'
required?: boolean
maxlength?: number
min?: number
max?: number
imageType?: number
}[] = [
{ name: 'name', label: 'Task Name', type: 'text', required: true, maxlength: 64 },
{ name: 'points', label: 'Task Points', type: 'number', required: true, min: 1, max: 100 },
{ name: 'is_good', label: 'Task Type', type: 'custom' },
{ name: 'image_id', label: 'Image', type: 'image', imageType: 2 },
]
const initialData = ref({ name: '', points: 1, image_id: null, is_good: true })
const isGood = ref(true) const isGood = ref(true)
const selectedImageId = ref<string | null>(null)
const localImageFile = ref<File | null>(null) const localImageFile = ref<File | null>(null)
const nameInput = ref<HTMLInputElement | null>(null)
const loading = ref(false) const loading = ref(false)
const error = ref<string | null>(null) const error = ref<string | null>(null)
// Load task if editing
onMounted(async () => { onMounted(async () => {
if (isEdit.value) { if (isEdit.value && props.id) {
loading.value = true loading.value = true
try { try {
const resp = await fetch(`/api/task/${props.id}`) const resp = await fetch(`/api/task/${props.id}`)
if (!resp.ok) throw new Error('Failed to load task') if (!resp.ok) throw new Error('Failed to load task')
const data = await resp.json() const data = await resp.json()
name.value = data.name initialData.value = {
points.value = Number(data.points) || 0 name: data.name ?? '',
points: Number(data.points) || 1,
image_id: data.image_id ?? null,
is_good: data.is_good,
}
isGood.value = data.is_good isGood.value = data.is_good
selectedImageId.value = data.image_id } catch {
} catch (e) {
error.value = 'Could not load task.' error.value = 'Could not load task.'
} finally { } finally {
loading.value = false loading.value = false
// Delay focus until after DOM updates and event propagation
await nextTick() await nextTick()
nameInput.value?.focus()
} }
} else {
// For create, also use nextTick
await nextTick()
nameInput.value?.focus()
} }
}) })
const submit = async () => { function handleAddImage({ id, file }: { id: string; file: File }) {
let imageId = selectedImageId.value if (id === 'local-upload') {
localImageFile.value = file
}
}
async function handleSubmit(form: {
name: string
points: number
image_id: string | null
is_good: boolean
}) {
let imageId = form.image_id
error.value = null error.value = null
if (!name.value.trim()) { if (!form.name.trim()) {
error.value = 'Task name is required.' error.value = 'Task name is required.'
return return
} }
if (points.value < 1) { if (form.points < 1) {
error.value = 'Points must be at least 1.' error.value = 'Points must be at least 1.'
return return
} }
@@ -80,8 +136,8 @@ const submit = async () => {
if (!resp.ok) throw new Error('Image upload failed') if (!resp.ok) throw new Error('Image upload failed')
const data = await resp.json() const data = await resp.json()
imageId = data.id imageId = data.id
} catch (err) { } catch {
alert('Failed to upload image.') error.value = 'Failed to upload image.'
loading.value = false loading.value = false
return return
} }
@@ -90,14 +146,14 @@ const submit = async () => {
// Now update or create the task // Now update or create the task
try { try {
let resp let resp
if (isEdit.value) { if (isEdit.value && props.id) {
resp = await fetch(`/api/task/${props.id}/edit`, { resp = await fetch(`/api/task/${props.id}/edit`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
name: name.value, name: form.name,
points: points.value, points: form.points,
is_good: isGood.value, is_good: form.is_good,
image_id: imageId, image_id: imageId,
}), }),
}) })
@@ -106,18 +162,17 @@ const submit = async () => {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
name: name.value, name: form.name,
points: points.value, points: form.points,
is_good: isGood.value, is_good: form.is_good,
image_id: imageId, image_id: imageId,
}), }),
}) })
} }
if (!resp.ok) throw new Error('Failed to save task') if (!resp.ok) throw new Error('Failed to save task')
emit('updated')
await router.push({ name: 'TaskView' }) await router.push({ name: 'TaskView' })
} catch (err) { } catch {
alert('Failed to save task.') error.value = 'Failed to save task.'
} }
loading.value = false loading.value = false
} }
@@ -125,131 +180,37 @@ const submit = async () => {
function handleCancel() { function handleCancel() {
router.back() router.back()
} }
// Handle new image from ImagePicker
function onAddImage({ id, file }: { id: string; file: File }) {
if (id === 'local-upload') {
localImageFile.value = file
}
}
</script> </script>
<template> <template>
<div class="task-edit-view"> <EntityEditForm
<h2>{{ isEdit ? 'Edit Task' : 'Create Task' }}</h2> entityLabel="Task"
<div v-if="loading" class="loading-message">Loading task...</div> :fields="fields"
<form v-else @submit.prevent="submit" class="task-form"> :initialData="initialData"
<div class="group"> :isEdit="isEdit"
<label for="task-name"> :loading="loading"
Task Name :error="error"
<input @submit="handleSubmit"
id="task-name" @cancel="handleCancel"
ref="nameInput" @add-image="handleAddImage"
v-model="name" >
type="text" <template #custom-field-is_good="{ modelValue, update }">
required <div class="good-bad-toggle">
maxlength="64" <button
/> type="button"
</label> :class="['toggle-btn', modelValue ? 'good-active' : '']"
</div> @click="update(true)"
<div class="group"> >
<label for="task-points"> Good
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>
<button type="submit" :disabled="loading" class="btn btn-primary"> <button
{{ isEdit ? 'Save' : 'Create' }} type="button"
:class="['toggle-btn', !modelValue ? 'bad-active' : '']"
@click="update(false)"
>
Bad
</button> </button>
</div> </div>
</form> </template>
</div> </EntityEditForm>
</template> </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

@@ -369,4 +369,18 @@ function updateLocalImage(url: string, file: File) {
margin-right: auto; margin-right: auto;
object-fit: contain; object-fit: contain;
} }
.actions {
display: flex;
gap: 3rem;
justify-content: center;
margin-top: 0.5rem;
margin-bottom: 0.4rem;
}
.actions .btn {
padding: 1rem 2.2rem;
font-weight: 700;
font-size: 1.25rem;
min-width: 120px;
}
</style> </style>