Files
chore/frontend/src/components/routine/RoutineEditView.vue
Ryan Kegel 0b3d1d5ed0
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m10s
feat: replace drag-and-drop with pointer events for item reordering
2026-05-23 23:28:11 -04:00

708 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>