All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m10s
708 lines
18 KiB
Vue
708 lines
18 KiB
Vue
<template>
|
||
<div class="view">
|
||
<EntityEditForm
|
||
entityLabel="Routine"
|
||
:fields="fields"
|
||
:initialData="initialData"
|
||
:isEdit="isEdit"
|
||
:loading="loading"
|
||
:error="error"
|
||
:requireDirty="false"
|
||
:submitDisabled="items.length === 0"
|
||
@submit="handleSubmit"
|
||
@cancel="handleCancel"
|
||
@add-image="handleAddMainImage"
|
||
>
|
||
<template #before-actions>
|
||
<section class="items-panel">
|
||
<h3>Routine Tasks</h3>
|
||
|
||
<div v-if="items.length === 0" class="items-empty">
|
||
Add at least one task to save this routine.
|
||
</div>
|
||
|
||
<div v-else class="items-list">
|
||
<div
|
||
v-for="(item, idx) in items"
|
||
:key="item.id || `new-${idx}`"
|
||
class="item-row"
|
||
:data-idx="idx"
|
||
:class="{ 'drag-over': dragOverIdx === idx, dragging: draggingIdx === idx }"
|
||
>
|
||
<span class="drag-handle" title="Drag to reorder" @pointerdown.prevent="(e) => onPointerDown(e, idx)">⠿</span>
|
||
<div class="item-left">
|
||
<img
|
||
v-if="item.image_url"
|
||
:src="item.image_url"
|
||
:alt="item.name"
|
||
class="item-thumb"
|
||
/>
|
||
<span class="item-name">{{ item.name }}</span>
|
||
</div>
|
||
<div class="item-actions">
|
||
<button
|
||
type="button"
|
||
class="btn btn-secondary small-btn"
|
||
@click="startEditItem(idx)"
|
||
>
|
||
Edit
|
||
</button>
|
||
<button type="button" class="btn btn-secondary small-btn" @click="removeItem(idx)">
|
||
Delete
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<button v-if="!addFormOpen" type="button" class="add-task-trigger" @click="openAddForm">
|
||
<span class="add-task-plus">+</span> Add Task
|
||
</button>
|
||
|
||
<div v-else class="add-item-form">
|
||
<div class="add-form-header">
|
||
<h4>{{ editingItemIdx !== null ? 'Edit Task' : 'New Task' }}</h4>
|
||
<button
|
||
type="button"
|
||
class="close-form-btn"
|
||
@click="cancelEditItem"
|
||
aria-label="Close"
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
|
||
<div class="group">
|
||
<input
|
||
id="item-name"
|
||
v-model="newItem.name"
|
||
type="text"
|
||
maxlength="64"
|
||
placeholder="Task name, e.g. Make bed"
|
||
@input="error = null"
|
||
autofocus
|
||
/>
|
||
</div>
|
||
|
||
<div v-if="showImagePicker" class="group">
|
||
<ImagePicker
|
||
id="item-image"
|
||
v-model="newItem.image_id"
|
||
:image-type="2"
|
||
@add-image="handleAddItemImage"
|
||
/>
|
||
</div>
|
||
<button v-else type="button" class="toggle-image-btn" @click="showImagePicker = true">
|
||
+ Add image (optional)
|
||
</button>
|
||
|
||
<div class="item-form-actions">
|
||
<button
|
||
type="button"
|
||
class="btn btn-primary"
|
||
@click="addOrUpdateItem"
|
||
:disabled="!newItem.name.trim()"
|
||
>
|
||
{{ editingItemIdx !== null ? 'Update' : 'Add' }}
|
||
</button>
|
||
<button type="button" class="btn btn-secondary" @click="cancelEditItem">
|
||
Cancel
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</template>
|
||
</EntityEditForm>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, onMounted, computed } from 'vue'
|
||
import { useRouter } from 'vue-router'
|
||
import EntityEditForm from '@/components/shared/EntityEditForm.vue'
|
||
import ImagePicker from '@/components/utils/ImagePicker.vue'
|
||
import { getCachedImageUrl } from '@/common/imageCache'
|
||
import type { RoutineItem } from '@/common/models'
|
||
import '@/assets/styles.css'
|
||
|
||
const props = defineProps<{ id?: string }>()
|
||
const router = useRouter()
|
||
const isEdit = computed(() => !!props.id)
|
||
|
||
type EditableRoutineItem = RoutineItem & {
|
||
image_url?: string | null
|
||
local_file?: File | null
|
||
}
|
||
|
||
const fields = [
|
||
{ name: 'name', label: 'Routine Name', type: 'text' as const, required: true, maxlength: 64 },
|
||
{ name: 'points', label: 'Points', type: 'number' as const, required: true, min: 1, max: 1000 },
|
||
{ name: 'image_id', label: 'Image', type: 'image' as const, imageType: 2 },
|
||
]
|
||
|
||
const initialData = ref({
|
||
name: '',
|
||
points: 1,
|
||
image_id: null as string | null,
|
||
})
|
||
const items = ref<EditableRoutineItem[]>([])
|
||
const newItem = ref({
|
||
name: '',
|
||
image_id: null as string | null,
|
||
})
|
||
const newItemLocalFile = ref<File | null>(null)
|
||
const editingItemIdx = ref<number | null>(null)
|
||
const addFormOpen = ref(false)
|
||
const showImagePicker = ref(false)
|
||
const loading = ref(false)
|
||
const error = ref<string | null>(null)
|
||
const localImageFile = ref<File | null>(null)
|
||
const removedItemIds = ref<string[]>([])
|
||
const draggingIdx = ref<number | null>(null)
|
||
const dragOverIdx = ref<number | null>(null)
|
||
|
||
onMounted(async () => {
|
||
if (isEdit.value && props.id) {
|
||
loading.value = true
|
||
try {
|
||
const resp = await fetch(`/api/routine/${props.id}`)
|
||
if (!resp.ok) throw new Error('Failed to load routine')
|
||
const data = await resp.json()
|
||
initialData.value = {
|
||
name: data.name ?? '',
|
||
points: Number(data.points) || 1,
|
||
image_id: data.image_id ?? null,
|
||
}
|
||
|
||
const itemsResp = await fetch(`/api/routine/${props.id}/items`)
|
||
if (itemsResp.ok) {
|
||
const itemsData = await itemsResp.json()
|
||
const loadedItems = (itemsData.items || []) as RoutineItem[]
|
||
items.value = await Promise.all(
|
||
loadedItems.map(async (item, idx) => {
|
||
let imageUrl: string | null = null
|
||
if (item.image_id) {
|
||
try {
|
||
imageUrl = await getCachedImageUrl(item.image_id)
|
||
} catch {
|
||
imageUrl = null
|
||
}
|
||
}
|
||
return {
|
||
...item,
|
||
order: idx,
|
||
image_url: imageUrl,
|
||
local_file: null,
|
||
}
|
||
}),
|
||
)
|
||
}
|
||
} catch {
|
||
error.value = 'Could not load routine.'
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
})
|
||
|
||
function handleAddMainImage({ id, file }: { id: string; file: File }) {
|
||
if (id === 'local-upload') {
|
||
localImageFile.value = file
|
||
} else {
|
||
localImageFile.value = null
|
||
}
|
||
}
|
||
|
||
async function handleAddItemImage({ id, file }: { id: string; file: File }) {
|
||
if (id !== 'local-upload') {
|
||
newItemLocalFile.value = null
|
||
return
|
||
}
|
||
|
||
newItemLocalFile.value = file
|
||
try {
|
||
const imageUrl = await getCachedImageUrl(id)
|
||
if (editingItemIdx.value !== null) {
|
||
const editingItem = items.value[editingItemIdx.value]
|
||
if (editingItem) {
|
||
editingItem.image_url = imageUrl
|
||
}
|
||
}
|
||
} catch {
|
||
// No-op: preview still works through ImagePicker.
|
||
}
|
||
}
|
||
|
||
function openAddForm() {
|
||
editingItemIdx.value = null
|
||
newItem.value = { name: '', image_id: null }
|
||
newItemLocalFile.value = null
|
||
showImagePicker.value = false
|
||
addFormOpen.value = true
|
||
}
|
||
|
||
function startEditItem(idx: number) {
|
||
const item = items.value[idx]
|
||
if (!item) return
|
||
|
||
editingItemIdx.value = idx
|
||
newItem.value = {
|
||
name: item.name,
|
||
image_id: item.image_id,
|
||
}
|
||
newItemLocalFile.value = null
|
||
showImagePicker.value = !!item.image_id
|
||
addFormOpen.value = true
|
||
}
|
||
|
||
function normalizeItemOrder() {
|
||
items.value = items.value.map((item, idx) => ({
|
||
...item,
|
||
order: idx,
|
||
}))
|
||
}
|
||
|
||
function addOrUpdateItem() {
|
||
if (!newItem.value.name.trim()) {
|
||
error.value = 'Item name is required.'
|
||
return
|
||
}
|
||
|
||
if (editingItemIdx.value !== null) {
|
||
const current = items.value[editingItemIdx.value]
|
||
if (!current) {
|
||
cancelEditItem()
|
||
return
|
||
}
|
||
|
||
items.value[editingItemIdx.value] = {
|
||
id: current.id || '',
|
||
routine_id: props.id || '',
|
||
name: newItem.value.name,
|
||
image_id: newItem.value.image_id,
|
||
image_url: current.image_url,
|
||
local_file: newItemLocalFile.value,
|
||
order: editingItemIdx.value,
|
||
}
|
||
} else {
|
||
items.value.push({
|
||
id: `temp-${Date.now()}`,
|
||
routine_id: props.id || '',
|
||
name: newItem.value.name,
|
||
image_id: newItem.value.image_id,
|
||
image_url: null,
|
||
local_file: newItemLocalFile.value,
|
||
order: items.value.length,
|
||
})
|
||
}
|
||
|
||
normalizeItemOrder()
|
||
editingItemIdx.value = null
|
||
addFormOpen.value = false
|
||
showImagePicker.value = false
|
||
newItem.value = { name: '', image_id: null }
|
||
newItemLocalFile.value = null
|
||
error.value = null
|
||
}
|
||
|
||
function removeItem(idx: number) {
|
||
const item = items.value[idx]
|
||
if (!item) return
|
||
|
||
if (item.id && !item.id.startsWith('temp-')) {
|
||
removedItemIds.value.push(item.id)
|
||
}
|
||
items.value.splice(idx, 1)
|
||
normalizeItemOrder()
|
||
|
||
if (editingItemIdx.value === idx) {
|
||
cancelEditItem()
|
||
} else if (editingItemIdx.value !== null && editingItemIdx.value > idx) {
|
||
editingItemIdx.value -= 1
|
||
}
|
||
}
|
||
|
||
function cancelEditItem() {
|
||
editingItemIdx.value = null
|
||
addFormOpen.value = false
|
||
showImagePicker.value = false
|
||
newItem.value = { name: '', image_id: null }
|
||
newItemLocalFile.value = null
|
||
}
|
||
|
||
function onPointerDown(e: PointerEvent, idx: number) {
|
||
draggingIdx.value = idx
|
||
cancelEditItem()
|
||
|
||
const handlePointerMove = (moveEvt: PointerEvent) => {
|
||
moveEvt.preventDefault()
|
||
const el = document.elementFromPoint(moveEvt.clientX, moveEvt.clientY) as Element | null
|
||
if (!el) return
|
||
const row = el.closest('[data-idx]') as HTMLElement | null
|
||
if (!row) return
|
||
const rowIdx = parseInt(row.dataset.idx ?? '-1')
|
||
if (rowIdx !== -1 && rowIdx !== draggingIdx.value) {
|
||
dragOverIdx.value = rowIdx
|
||
}
|
||
}
|
||
|
||
const cleanup = (upEvt?: PointerEvent) => {
|
||
if (upEvt) {
|
||
const el = document.elementFromPoint(upEvt.clientX, upEvt.clientY) as Element | null
|
||
const fromIdx = draggingIdx.value
|
||
if (el && fromIdx !== null) {
|
||
const row = el.closest('[data-idx]') as HTMLElement | null
|
||
if (row) {
|
||
const toIdx = parseInt(row.dataset.idx ?? '-1')
|
||
if (toIdx !== -1 && toIdx !== fromIdx) {
|
||
const moved = items.value.splice(fromIdx, 1)[0]
|
||
items.value.splice(toIdx, 0, moved)
|
||
normalizeItemOrder()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
draggingIdx.value = null
|
||
dragOverIdx.value = null
|
||
document.removeEventListener('pointermove', handlePointerMove)
|
||
document.removeEventListener('pointerup', handlePointerUp)
|
||
document.removeEventListener('pointercancel', handlePointerCancel)
|
||
}
|
||
|
||
const handlePointerUp = (upEvt: PointerEvent) => cleanup(upEvt)
|
||
const handlePointerCancel = () => cleanup()
|
||
|
||
document.addEventListener('pointermove', handlePointerMove, { passive: false })
|
||
document.addEventListener('pointerup', handlePointerUp)
|
||
document.addEventListener('pointercancel', handlePointerCancel)
|
||
}
|
||
|
||
async function uploadImage(file: File): Promise<string> {
|
||
const formData = new FormData()
|
||
formData.append('file', file)
|
||
formData.append('type', '2')
|
||
formData.append('permanent', 'false')
|
||
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()
|
||
return data.id
|
||
}
|
||
|
||
async function resolveItemImageId(item: EditableRoutineItem): Promise<string | null> {
|
||
if (item.image_id === 'local-upload' && item.local_file) {
|
||
return uploadImage(item.local_file)
|
||
}
|
||
return item.image_id ?? null
|
||
}
|
||
|
||
async function handleSubmit(form: { name: string; points: number; image_id: string | null }) {
|
||
error.value = null
|
||
if (!form.name.trim()) {
|
||
error.value = 'Routine name is required.'
|
||
return
|
||
}
|
||
if (form.points < 1) {
|
||
error.value = 'Points must be at least 1.'
|
||
return
|
||
}
|
||
if (items.value.length === 0) {
|
||
error.value = 'Routine must have at least one item.'
|
||
return
|
||
}
|
||
|
||
loading.value = true
|
||
|
||
try {
|
||
let imageId = form.image_id
|
||
if (imageId === null && localImageFile.value) {
|
||
imageId = await uploadImage(localImageFile.value)
|
||
}
|
||
if (imageId === 'local-upload' && localImageFile.value) {
|
||
imageId = await uploadImage(localImageFile.value)
|
||
}
|
||
|
||
const url = isEdit.value && props.id ? `/api/routine/${props.id}/edit` : '/api/routine/add'
|
||
const resp = await fetch(url, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name: form.name, points: form.points, image_id: imageId }),
|
||
})
|
||
if (!resp.ok) throw new Error('Failed to save routine')
|
||
const routineData = await resp.json()
|
||
const routineId =
|
||
props.id ||
|
||
(typeof routineData?.id === 'string' && routineData.id) ||
|
||
(typeof routineData?.routine?.id === 'string' && routineData.routine.id) ||
|
||
''
|
||
|
||
if (!routineId) {
|
||
throw new Error('Failed to resolve routine id after save')
|
||
}
|
||
|
||
for (const itemId of removedItemIds.value) {
|
||
const deleteResp = await fetch(`/api/routine/${routineId}/item/${itemId}`, {
|
||
method: 'DELETE',
|
||
})
|
||
if (!deleteResp.ok) {
|
||
throw new Error('Failed to delete routine item')
|
||
}
|
||
}
|
||
|
||
for (const item of items.value) {
|
||
const resolvedImageId = await resolveItemImageId(item)
|
||
const payload = { name: item.name, image_id: resolvedImageId, order: item.order }
|
||
|
||
if (item.id?.startsWith('temp-') || !item.id) {
|
||
const addItemResp = await fetch(`/api/routine/${routineId}/item/add`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload),
|
||
})
|
||
if (!addItemResp.ok) {
|
||
throw new Error('Failed to add routine item')
|
||
}
|
||
} else {
|
||
const editItemResp = await fetch(`/api/routine/${routineId}/item/${item.id}/edit`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload),
|
||
})
|
||
if (!editItemResp.ok) {
|
||
throw new Error('Failed to edit routine item')
|
||
}
|
||
}
|
||
}
|
||
|
||
await router.push({ name: 'RoutineView' })
|
||
} catch (err) {
|
||
error.value = 'Failed to save routine.'
|
||
console.error(err)
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
function handleCancel() {
|
||
router.back()
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.view {
|
||
max-width: 400px;
|
||
margin: 0 auto;
|
||
background: var(--form-bg);
|
||
border-radius: 12px;
|
||
box-shadow: 0 4px 24px var(--form-shadow);
|
||
padding: 2rem 2.2rem 1.5rem 2.2rem;
|
||
}
|
||
|
||
.items-panel {
|
||
margin-top: 1rem;
|
||
border-top: 1px solid var(--form-input-border, #e6e6e6);
|
||
padding-top: 1rem;
|
||
}
|
||
|
||
.items-panel h3 {
|
||
color: var(--form-label, #444);
|
||
font-size: 1.1rem;
|
||
margin: 0 0 0.75rem;
|
||
}
|
||
|
||
.items-empty {
|
||
color: var(--loading-color, #888);
|
||
font-size: 0.95rem;
|
||
margin-bottom: 0.75rem;
|
||
}
|
||
|
||
.items-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.6rem;
|
||
margin-bottom: 1rem;
|
||
}
|
||
|
||
.item-row {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
background: var(--form-input-bg, #f8fafc);
|
||
border: 1px solid var(--form-input-border, #e6e6e6);
|
||
border-radius: 10px;
|
||
padding: 0.55rem 0.7rem;
|
||
cursor: default;
|
||
}
|
||
|
||
.item-row.drag-over {
|
||
border-color: var(--btn-primary, #667eea);
|
||
background: var(--form-input-bg-hover, #f0f4ff);
|
||
}
|
||
|
||
.item-row.dragging {
|
||
opacity: 0.4;
|
||
}
|
||
|
||
.drag-handle {
|
||
cursor: grab;
|
||
color: var(--form-label, #aaa);
|
||
font-size: 1.15rem;
|
||
padding: 0.4rem 0.45rem 0.4rem 0;
|
||
user-select: none;
|
||
touch-action: none;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.drag-handle:active {
|
||
cursor: grabbing;
|
||
}
|
||
|
||
.item-left {
|
||
display: flex;
|
||
align-items: center;
|
||
min-width: 0;
|
||
gap: 0.6rem;
|
||
flex: 1;
|
||
}
|
||
|
||
.item-thumb {
|
||
width: 34px;
|
||
height: 34px;
|
||
border-radius: 8px;
|
||
object-fit: cover;
|
||
}
|
||
|
||
.item-name {
|
||
font-weight: 600;
|
||
color: var(--form-label, #333);
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.item-actions {
|
||
display: flex;
|
||
gap: 0.5rem;
|
||
}
|
||
|
||
.add-task-trigger {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.4rem;
|
||
width: 100%;
|
||
padding: 0.6rem 0.9rem;
|
||
background: none;
|
||
border: 1.5px dashed var(--form-input-border, #c8d0db);
|
||
border-radius: 10px;
|
||
color: var(--btn-primary, #667eea);
|
||
font-size: 0.95rem;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
transition:
|
||
background 0.15s,
|
||
border-color 0.15s;
|
||
}
|
||
|
||
.add-task-trigger:hover {
|
||
background: var(--form-input-bg, #f0f4ff);
|
||
border-color: var(--btn-primary, #667eea);
|
||
}
|
||
|
||
.add-task-plus {
|
||
font-size: 1.1rem;
|
||
line-height: 1;
|
||
}
|
||
|
||
.add-item-form {
|
||
margin-top: 0.25rem;
|
||
background: var(--form-input-bg, #f8fafc);
|
||
border: 1px solid var(--form-input-border, #e6e6e6);
|
||
border-radius: 10px;
|
||
padding: 0.8rem;
|
||
}
|
||
|
||
.add-form-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: 0.65rem;
|
||
}
|
||
|
||
.add-form-header h4 {
|
||
color: var(--form-label, #444);
|
||
font-size: 1rem;
|
||
margin: 0;
|
||
}
|
||
|
||
.close-form-btn {
|
||
background: none;
|
||
border: none;
|
||
color: var(--form-label, #888);
|
||
font-size: 1rem;
|
||
cursor: pointer;
|
||
line-height: 1;
|
||
padding: 0.1rem 0.3rem;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.close-form-btn:hover {
|
||
background: rgba(0, 0, 0, 0.06);
|
||
}
|
||
|
||
.toggle-image-btn {
|
||
background: none;
|
||
border: none;
|
||
color: var(--btn-primary, #667eea);
|
||
font-size: 0.88rem;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
padding: 0;
|
||
margin-bottom: 0.8rem;
|
||
display: block;
|
||
}
|
||
|
||
.toggle-image-btn:hover {
|
||
text-decoration: underline;
|
||
}
|
||
|
||
.group {
|
||
margin-bottom: 0.8rem;
|
||
}
|
||
|
||
.group input[type='text'] {
|
||
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;
|
||
}
|
||
|
||
.item-form-actions {
|
||
display: flex;
|
||
gap: 0.5rem;
|
||
}
|
||
|
||
.small-btn {
|
||
padding: 0.4rem 0.8rem;
|
||
font-size: 0.85rem;
|
||
min-width: 0;
|
||
}
|
||
|
||
@media (max-width: 600px) {
|
||
.view {
|
||
padding: 1.5rem 1.2rem;
|
||
}
|
||
|
||
.item-row {
|
||
align-items: flex-start;
|
||
flex-direction: column;
|
||
gap: 0.6rem;
|
||
}
|
||
|
||
.item-actions {
|
||
width: 100%;
|
||
justify-content: flex-end;
|
||
}
|
||
}
|
||
</style>
|