feat: Implement drag-and-drop reordering for routine items and add corresponding E2E tests
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m41s
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m41s
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
67
CLAUDE.md
Normal file
67
CLAUDE.md
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project
|
||||||
|
|
||||||
|
Family chore/reward manager. Flask + TinyDB backend (`backend/`), Vue 3 + TypeScript frontend (`frontend/`). Real-time updates flow over Server-Sent Events.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
### Backend (run from `backend/`)
|
||||||
|
- Activate venv first: `source .venv/bin/activate` (mac/linux) — Python runs from `backend/.venv/`.
|
||||||
|
- Dev server: `python -m flask run --host=0.0.0.0 --port=5000` (entry: `backend/main.py`).
|
||||||
|
- Required env vars at startup: `SECRET_KEY`, `REFRESH_TOKEN_EXPIRY_DAYS`, `DIGEST_TOKEN_SECRET`, `VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY` — Flask raises `RuntimeError` on boot if any are missing.
|
||||||
|
- Optional: `DB_ENV` / `DATA_ENV` (`prod` | `test` | `e2e`) — picks `data/` vs `test_data/` directory (see `backend/config/paths.py`).
|
||||||
|
- Tests: `pytest tests/` — `tests/conftest.py` forces `DB_ENV=test` and sets dummy secrets. Single test: `pytest tests/test_routine_api.py::test_name`.
|
||||||
|
- Create an admin user (cannot be done via signup): `python scripts/create_admin.py`.
|
||||||
|
|
||||||
|
### Frontend (run from `frontend/`)
|
||||||
|
- Dev: `npm run dev` (Vite, https://localhost:5173).
|
||||||
|
- Build: `npm run build`. Type-check: `npm run type-check`. Lint: `npm run lint`.
|
||||||
|
- Unit/component tests: `npm run test:unit` (Vitest). Single test: `npx vitest run path/to/file.spec.ts`.
|
||||||
|
- E2E: `npx playwright test` from `frontend/`. Config at `playwright.config.ts` auto-starts both `npm run dev` and the Flask backend with `DB_ENV=e2e DATA_ENV=e2e`, so test data lands in `backend/test_data/` and never touches production. The `globalSetup` seeds the DB and logs in; tests receive a pre-authenticated session via `storageState` — do **not** navigate to `/auth/login`. Import `E2E_EMAIL` / `E2E_PASSWORD` from `e2e/e2e-constants.ts` rather than hardcoding.
|
||||||
|
- E2E suite is split into Playwright "projects" in `playwright.config.ts` (`chromium-routines`, `chromium-task-assignment`, …) — each bucket targets a directory under `e2e/mode_parent/` and some use isolated users to avoid cross-bucket interference. Run a single bucket: `npx playwright test --project=chromium-routines`.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### API surface and nginx proxy
|
||||||
|
- Each entity has its own Flask blueprint in `backend/api/` (`child_api.py`, `chore_api.py`, `routine_api.py`, …). Registered in `backend/main.py`.
|
||||||
|
- The frontend nginx (and Vite dev proxy) strips `/api` before forwarding. **Backend routes must NOT include `/api`** — backend defines `@app.route('/user')`, frontend calls `/api/user`.
|
||||||
|
- The `auth_api` blueprint is the only one mounted under a prefix (`/auth`).
|
||||||
|
- API errors return `{ error, code }`; codes live in `backend/api/error_codes.py`. Frontend extracts them via `parseErrorResponse(res)` in `frontend/src/common/api.ts`.
|
||||||
|
|
||||||
|
### Models — keep 1:1 parity
|
||||||
|
- Python `@dataclass`es live in `backend/models/`. TypeScript interfaces live in `frontend/src/common/models.ts`. Any model change requires updating **both**.
|
||||||
|
- Persistence is TinyDB (JSON files under `data/db/` or `test_data/db/`). All DB access goes through the thread-safe `LockedTable` wrapper in `backend/db/db.py`. Always operate on model instances using `from_dict()` / `to_dict()` — never raw dicts.
|
||||||
|
|
||||||
|
### SSE event bus (mandatory for every mutation)
|
||||||
|
- Every backend mutation (add/edit/delete/trigger) **must** call `send_event_for_current_user` (from `api/utils.py`). Event types live in `backend/events/types/` and `frontend/src/common/backendEvents.ts` (mirrored).
|
||||||
|
- Frontend state is event-driven: register listeners in `onMounted`, clean up in `onUnmounted`. See `components/BackendEventsListener.vue` and `src/common/backendEvents.ts`.
|
||||||
|
- The SSE endpoint is `/events`; per-user queues live in `backend/events/sse.py`.
|
||||||
|
|
||||||
|
### Background schedulers
|
||||||
|
Started in `backend/main.py` at boot:
|
||||||
|
- `start_deletion_scheduler` — runs hourly, deletes accounts that were marked-for-deletion at least `ACCOUNT_DELETION_THRESHOLD_HOURS` ago (default 720, min 24, max 720). Cleans pending rewards, children, tasks, rewards, images, then the user. Logs to `logs/account_deletion.log`.
|
||||||
|
- `start_digest_scheduler` — email digests.
|
||||||
|
- `start_state_expiry_scheduler` — expires stale state.
|
||||||
|
- `start_chore_expiry_notification_scheduler` — chore expiry notifications.
|
||||||
|
|
||||||
|
### Auth & security
|
||||||
|
- JWT in HttpOnly + Secure + SameSite=Strict cookies. Verification tokens expire in 4 hours; password-reset tokens in 10 minutes.
|
||||||
|
- Admin role is **never** assignable via signup — use `backend/scripts/create_admin.py`. Admin endpoints under `/admin/*` enforce role check.
|
||||||
|
|
||||||
|
### Frontend conventions
|
||||||
|
- Vue SFC file order: `<template>` → `<script>` → `<style scoped>`. TypeScript only inside `<script>`. **All styles must be `scoped`.**
|
||||||
|
- Use **only** `:root` CSS variables from `colors.css` for colors/spacing/tokens (e.g. `--btn-primary`, `--list-item-bg-good`). No hardcoded hex/px values for themed properties.
|
||||||
|
- Layout shells: `ParentLayout` for admin/management views, `ChildLayout` for child dashboard/focus views.
|
||||||
|
- Images: models carry `image_id`; frontend resolves to `image_url` for rendering.
|
||||||
|
|
||||||
|
### Specs
|
||||||
|
Feature specs live in `.github/specs/`. If a spec has a checklist, all items must be marked done before the feature is considered complete.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- Backend Python imports assume `backend/` is on `sys.path` (added by `conftest.py` for tests, by `flask run` cwd in dev). Run pytest from `backend/`.
|
||||||
|
- Don't replace code with comments; mirror changes across backend + frontend so model/event parity holds.
|
||||||
|
- E2E tests share a single seeded user by default — buckets that mutate shared state (default tasks, delete-account, create-child) deliberately use isolated users; preserve that pattern when adding new buckets.
|
||||||
@@ -2,20 +2,20 @@
|
|||||||
"cookies": [
|
"cookies": [
|
||||||
{
|
{
|
||||||
"name": "refresh_token",
|
"name": "refresh_token",
|
||||||
"value": "QCuLIgmPV2vnDfzmgvptt-mZAW1jh8UML_wwAee4OTQ",
|
"value": "GyPtMIGYCphOLBJzZ6jG87S9PKImCOhrVEvuVL0-Wu8",
|
||||||
"domain": "localhost",
|
"domain": "localhost",
|
||||||
"path": "/api/auth",
|
"path": "/api/auth",
|
||||||
"expires": 1786912678.702645,
|
"expires": 1786993807.67933,
|
||||||
"httpOnly": true,
|
"httpOnly": true,
|
||||||
"secure": true,
|
"secure": true,
|
||||||
"sameSite": "Strict"
|
"sameSite": "Strict"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "access_token",
|
"name": "access_token",
|
||||||
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZUB0ZXN0LmNvbSIsInVzZXJfaWQiOiJlM2VmNGE2OC0zZWI3LTQ2MjQtYTI0Mi0yNmY1OTgyZDIwOWEiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3NzkxNDc0Nzh9.P7CDplAG3MBXgtEpuYFtp6gOV5pi-6QeAYRqmE-Dkys",
|
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZUB0ZXN0LmNvbSIsInVzZXJfaWQiOiJjNWUzMTkwYi1hZWIzLTRlMTEtYmFiNS1hNjBkOTM5NmEyN2QiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3NzkyMjg2MDd9.f7dGIujexK9ffYWPM5ExUbL_UPRg0ULo778AR2J_yf0",
|
||||||
"domain": "localhost",
|
"domain": "localhost",
|
||||||
"path": "/",
|
"path": "/",
|
||||||
"expires": 1779147478.701971,
|
"expires": 1779228607.679281,
|
||||||
"httpOnly": true,
|
"httpOnly": true,
|
||||||
"secure": true,
|
"secure": true,
|
||||||
"sameSite": "Lax"
|
"sameSite": "Lax"
|
||||||
@@ -27,11 +27,11 @@
|
|||||||
"localStorage": [
|
"localStorage": [
|
||||||
{
|
{
|
||||||
"name": "authSyncEvent",
|
"name": "authSyncEvent",
|
||||||
"value": "{\"type\":\"logout\",\"at\":1779136678495}"
|
"value": "{\"type\":\"logout\",\"at\":1779217807539}"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "parentAuth",
|
"name": "parentAuth",
|
||||||
"value": "{\"expiresAt\":1779309478870}"
|
"value": "{\"expiresAt\":1779390607821}"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { test, expect, type APIRequestContext } from '@playwright/test'
|
||||||
|
|
||||||
|
const ROUTINE_NAME = 'ReorderTestRoutine'
|
||||||
|
const ROUTINE_POINTS = 10
|
||||||
|
const ITEM_A = 'Alpha Task'
|
||||||
|
const ITEM_B = 'Beta Task'
|
||||||
|
const ITEM_C = 'Gamma Task'
|
||||||
|
|
||||||
|
async function createRoutine(
|
||||||
|
request: APIRequestContext,
|
||||||
|
name: string,
|
||||||
|
points: number,
|
||||||
|
): Promise<string> {
|
||||||
|
const pre = await request.get('/api/routine/list')
|
||||||
|
for (const r of (await pre.json()).routines ?? []) {
|
||||||
|
if (r.name === name) await request.delete(`/api/routine/${r.id}`)
|
||||||
|
}
|
||||||
|
const res = await request.put('/api/routine/add', { data: { name, points } })
|
||||||
|
return (await res.json()).routine?.id ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Routine item drag-to-reorder', () => {
|
||||||
|
test.describe.configure({ mode: 'serial' })
|
||||||
|
|
||||||
|
let routineId = ''
|
||||||
|
|
||||||
|
test.beforeAll(async ({ request }) => {
|
||||||
|
routineId = await createRoutine(request, ROUTINE_NAME, ROUTINE_POINTS)
|
||||||
|
await request.put(`/api/routine/${routineId}/item/add`, { data: { name: ITEM_A, order: 0 } })
|
||||||
|
await request.put(`/api/routine/${routineId}/item/add`, { data: { name: ITEM_B, order: 1 } })
|
||||||
|
await request.put(`/api/routine/${routineId}/item/add`, { data: { name: ITEM_C, order: 2 } })
|
||||||
|
})
|
||||||
|
|
||||||
|
test.afterAll(async ({ request }) => {
|
||||||
|
if (routineId) await request.delete(`/api/routine/${routineId}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('items load in correct initial order', async ({ page }) => {
|
||||||
|
await page.goto(`/parent/tasks/routines/${routineId}/edit`)
|
||||||
|
await page.locator('.item-row').first().waitFor({ state: 'visible' })
|
||||||
|
|
||||||
|
const names = await page.locator('.item-name').allTextContents()
|
||||||
|
expect(names).toEqual([ITEM_A, ITEM_B, ITEM_C])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('dragging first item to last position reorders the list in the UI', async ({ page }) => {
|
||||||
|
await page.goto(`/parent/tasks/routines/${routineId}/edit`)
|
||||||
|
await page.locator('.item-row').first().waitFor({ state: 'visible' })
|
||||||
|
|
||||||
|
await page.locator('.item-row').nth(0).dragTo(page.locator('.item-row').nth(2))
|
||||||
|
|
||||||
|
const names = await page.locator('.item-name').allTextContents()
|
||||||
|
expect(names).toEqual([ITEM_B, ITEM_C, ITEM_A])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('reordered item order persists after saving', async ({ page }) => {
|
||||||
|
await page.goto(`/parent/tasks/routines/${routineId}/edit`)
|
||||||
|
await page.locator('.item-row').first().waitFor({ state: 'visible' })
|
||||||
|
|
||||||
|
// Drag first to last: [A, B, C] → [B, C, A]
|
||||||
|
await page.locator('.item-row').nth(0).dragTo(page.locator('.item-row').nth(2))
|
||||||
|
|
||||||
|
// Confirm the drag updated the DOM before saving
|
||||||
|
const afterDrag = await page.locator('.item-name').allTextContents()
|
||||||
|
expect(afterDrag).toEqual([ITEM_B, ITEM_C, ITEM_A])
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Save' }).click()
|
||||||
|
await page.waitForURL(/\/parent\/tasks\/routines$/, { timeout: 5000 })
|
||||||
|
|
||||||
|
// Reload edit view — should now show the saved order
|
||||||
|
await page.goto(`/parent/tasks/routines/${routineId}/edit`)
|
||||||
|
await page.locator('.item-row').first().waitFor({ state: 'visible' })
|
||||||
|
|
||||||
|
const names = await page.locator('.item-name').allTextContents()
|
||||||
|
expect(names).toEqual([ITEM_B, ITEM_C, ITEM_A])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -709,6 +709,50 @@ onUnmounted(() => {
|
|||||||
<div v-if="!loading && !error" class="layout">
|
<div v-if="!loading && !error" class="layout">
|
||||||
<div class="main">
|
<div class="main">
|
||||||
<ChildDetailCard :child="child" />
|
<ChildDetailCard :child="child" />
|
||||||
|
<ScrollingList
|
||||||
|
v-if="routines.length > 0"
|
||||||
|
title="Routines"
|
||||||
|
ref="childRoutineListRef"
|
||||||
|
:fetchBaseUrl="`/api/child/${child?.id}/list-routines`"
|
||||||
|
:ids="routines"
|
||||||
|
itemKey="routines"
|
||||||
|
imageField="image_id"
|
||||||
|
:isParentAuthenticated="false"
|
||||||
|
:readyItemId="readyItemId"
|
||||||
|
@item-ready="handleItemReady"
|
||||||
|
@trigger-item="handleRoutineClick"
|
||||||
|
:getItemClass="
|
||||||
|
(item: ChildRoutine) => ({
|
||||||
|
good: true,
|
||||||
|
'routine-pending': item.pending_status === 'pending',
|
||||||
|
'routine-approved': item.pending_status === 'approved',
|
||||||
|
})
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<template #item="{ item }: { item: ChildRoutine }">
|
||||||
|
<span v-if="item.pending_status === 'pending'" class="chore-stamp pending-stamp"
|
||||||
|
>PENDING</span
|
||||||
|
>
|
||||||
|
<span v-else-if="item.pending_status === 'approved'" class="chore-stamp completed-stamp"
|
||||||
|
>APPROVED</span
|
||||||
|
>
|
||||||
|
<div class="item-name">{{ item.name }}</div>
|
||||||
|
<img
|
||||||
|
v-if="item.image_url"
|
||||||
|
:src="item.image_url"
|
||||||
|
alt="Routine Image"
|
||||||
|
class="item-image"
|
||||||
|
/>
|
||||||
|
<div class="item-points good-points">
|
||||||
|
{{
|
||||||
|
item.custom_value !== undefined && item.custom_value !== null
|
||||||
|
? item.custom_value
|
||||||
|
: item.points
|
||||||
|
}}
|
||||||
|
Points
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ScrollingList>
|
||||||
<ScrollingList
|
<ScrollingList
|
||||||
title="Chores"
|
title="Chores"
|
||||||
ref="childChoreListRef"
|
ref="childChoreListRef"
|
||||||
@@ -783,6 +827,7 @@ onUnmounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
</ScrollingList>
|
</ScrollingList>
|
||||||
<ScrollingList
|
<ScrollingList
|
||||||
|
v-show="childKindnessListRef?.items?.length > 0"
|
||||||
title="Kindness Acts"
|
title="Kindness Acts"
|
||||||
ref="childKindnessListRef"
|
ref="childKindnessListRef"
|
||||||
:fetchBaseUrl="`/api/child/${child?.id}/list-tasks`"
|
:fetchBaseUrl="`/api/child/${child?.id}/list-tasks`"
|
||||||
@@ -810,6 +855,7 @@ onUnmounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
</ScrollingList>
|
</ScrollingList>
|
||||||
<ScrollingList
|
<ScrollingList
|
||||||
|
v-show="childPenaltyListRef?.items?.length > 0"
|
||||||
title="Penalties"
|
title="Penalties"
|
||||||
ref="childPenaltyListRef"
|
ref="childPenaltyListRef"
|
||||||
:fetchBaseUrl="`/api/child/${child?.id}/list-tasks`"
|
:fetchBaseUrl="`/api/child/${child?.id}/list-tasks`"
|
||||||
@@ -836,49 +882,6 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</ScrollingList>
|
</ScrollingList>
|
||||||
<ScrollingList
|
|
||||||
title="Routines"
|
|
||||||
ref="childRoutineListRef"
|
|
||||||
:fetchBaseUrl="`/api/child/${child?.id}/list-routines`"
|
|
||||||
:ids="routines"
|
|
||||||
itemKey="routines"
|
|
||||||
imageField="image_id"
|
|
||||||
:isParentAuthenticated="false"
|
|
||||||
:readyItemId="readyItemId"
|
|
||||||
@item-ready="handleItemReady"
|
|
||||||
@trigger-item="handleRoutineClick"
|
|
||||||
:getItemClass="
|
|
||||||
(item: ChildRoutine) => ({
|
|
||||||
good: true,
|
|
||||||
'routine-pending': item.pending_status === 'pending',
|
|
||||||
'routine-approved': item.pending_status === 'approved',
|
|
||||||
})
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<template #item="{ item }: { item: ChildRoutine }">
|
|
||||||
<span v-if="item.pending_status === 'pending'" class="chore-stamp pending-stamp"
|
|
||||||
>PENDING</span
|
|
||||||
>
|
|
||||||
<span v-else-if="item.pending_status === 'approved'" class="chore-stamp completed-stamp"
|
|
||||||
>APPROVED</span
|
|
||||||
>
|
|
||||||
<div class="item-name">{{ item.name }}</div>
|
|
||||||
<img
|
|
||||||
v-if="item.image_url"
|
|
||||||
:src="item.image_url"
|
|
||||||
alt="Routine Image"
|
|
||||||
class="item-image"
|
|
||||||
/>
|
|
||||||
<div class="item-points good-points">
|
|
||||||
{{
|
|
||||||
item.custom_value !== undefined && item.custom_value !== null
|
|
||||||
? item.custom_value
|
|
||||||
: item.points
|
|
||||||
}}
|
|
||||||
Points
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</ScrollingList>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1585,16 +1585,16 @@ function goToAssignRoutines() {
|
|||||||
</div>
|
</div>
|
||||||
<div class="assign-buttons">
|
<div class="assign-buttons">
|
||||||
<button v-if="child" class="btn btn-primary" @click="goToAssignTasks">Assign Chores</button>
|
<button v-if="child" class="btn btn-primary" @click="goToAssignTasks">Assign Chores</button>
|
||||||
|
<button v-if="child" class="btn btn-primary" @click="goToAssignRoutines">
|
||||||
|
Assign Routines
|
||||||
|
</button>
|
||||||
|
<button v-if="child" class="btn btn-green" @click="goToAssignRewards">Assign Rewards</button>
|
||||||
<button v-if="child" class="btn btn-primary" @click="goToAssignKindness">
|
<button v-if="child" class="btn btn-primary" @click="goToAssignKindness">
|
||||||
Assign Kindness Acts
|
Assign Kindness Acts
|
||||||
</button>
|
</button>
|
||||||
<button v-if="child" class="btn btn-danger" @click="goToAssignBadHabits">
|
<button v-if="child" class="btn btn-danger" @click="goToAssignBadHabits">
|
||||||
Assign Penalties
|
Assign Penalties
|
||||||
</button>
|
</button>
|
||||||
<button v-if="child" class="btn btn-primary" @click="goToAssignRoutines">
|
|
||||||
Assign Routines
|
|
||||||
</button>
|
|
||||||
<button v-if="child" class="btn btn-green" @click="goToAssignRewards">Assign Rewards</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Pending Reward Dialog -->
|
<!-- Pending Reward Dialog -->
|
||||||
|
|||||||
@@ -12,15 +12,16 @@
|
|||||||
class="routine-item"
|
class="routine-item"
|
||||||
@click="toggleRoutine(routine.id)"
|
@click="toggleRoutine(routine.id)"
|
||||||
>
|
>
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
:checked="selectedIds.includes(routine.id)"
|
|
||||||
@change="toggleRoutine(routine.id)"
|
|
||||||
:aria-label="`Select ${routine.name}`"
|
|
||||||
/>
|
|
||||||
<img v-if="routine.image_url" :src="routine.image_url" :alt="routine.name" />
|
<img v-if="routine.image_url" :src="routine.image_url" :alt="routine.name" />
|
||||||
<span class="name">{{ routine.name }}</span>
|
<span class="name">{{ routine.name }}</span>
|
||||||
<span class="value">{{ routine.points }} pts</span>
|
<span class="value">{{ routine.points }} pts</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
v-model="selectedIds"
|
||||||
|
:value="routine.id"
|
||||||
|
:aria-label="`Select ${routine.name}`"
|
||||||
|
@click.stop
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -38,6 +39,7 @@ import { ref, onMounted } from 'vue'
|
|||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { setChildRoutines } from '@/common/api'
|
import { setChildRoutines } from '@/common/api'
|
||||||
import MessageBlock from '../shared/MessageBlock.vue'
|
import MessageBlock from '../shared/MessageBlock.vue'
|
||||||
|
import { getCachedImageUrl } from '@/common/imageCache'
|
||||||
import '@/assets/styles.css'
|
import '@/assets/styles.css'
|
||||||
import type { Routine } from '@/common/models'
|
import type { Routine } from '@/common/models'
|
||||||
|
|
||||||
@@ -61,11 +63,23 @@ async function fetchRoutines() {
|
|||||||
const childData = await childResp.json()
|
const childData = await childResp.json()
|
||||||
selectedIds.value = childData.routines || []
|
selectedIds.value = childData.routines || []
|
||||||
|
|
||||||
// Fetch all routines
|
// Fetch all routines and resolve images
|
||||||
const routinesResp = await fetch('/api/routine/list')
|
const routinesResp = await fetch('/api/routine/list')
|
||||||
if (!routinesResp.ok) throw new Error('Failed to fetch routines')
|
if (!routinesResp.ok) throw new Error('Failed to fetch routines')
|
||||||
const routinesData = await routinesResp.json()
|
const routinesData = await routinesResp.json()
|
||||||
routines.value = routinesData.routines || []
|
const rawRoutines: Routine[] = routinesData.routines || []
|
||||||
|
await Promise.all(
|
||||||
|
rawRoutines.map(async (r: any) => {
|
||||||
|
if (r.image_id) {
|
||||||
|
try {
|
||||||
|
r.image_url = await getCachedImageUrl(r.image_id)
|
||||||
|
} catch {
|
||||||
|
r.image_url = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
routines.value = rawRoutines
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch routines:', error)
|
console.error('Failed to fetch routines:', error)
|
||||||
}
|
}
|
||||||
@@ -135,52 +149,65 @@ function onCancel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.routine-selection {
|
.routine-selection {
|
||||||
|
flex: 0 1 auto;
|
||||||
|
max-width: 480px;
|
||||||
|
width: 100%;
|
||||||
|
max-height: calc(100vh - 4.5rem);
|
||||||
|
overflow-y: auto;
|
||||||
|
margin: 0.2rem 0 0 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.5rem;
|
gap: 0.7rem;
|
||||||
width: 100%;
|
background: var(--list-bg);
|
||||||
max-width: 500px;
|
padding: 0.2rem;
|
||||||
|
border-radius: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.routine-item {
|
.routine-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
justify-content: space-between;
|
||||||
padding: 0.75rem 1rem;
|
border: 2px outset var(--list-item-border-good);
|
||||||
border: 2px solid var(--list-item-border-good);
|
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
|
padding: 0.2rem 1rem;
|
||||||
background: var(--list-item-bg-good);
|
background: var(--list-item-bg-good);
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: border 0.18s;
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
|
margin-left: 0.2rem;
|
||||||
|
margin-right: 0.2rem;
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.03);
|
||||||
|
box-sizing: border-box;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition:
|
|
||||||
background 0.2s,
|
|
||||||
border-color 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.routine-item:hover {
|
|
||||||
background: var(--list-item-bg-good-hover, var(--list-item-bg-good));
|
|
||||||
border-color: var(--list-item-border-good-hover, var(--list-item-border-good));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.routine-item input[type='checkbox'] {
|
.routine-item input[type='checkbox'] {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
width: 1.25rem;
|
width: 1.2em;
|
||||||
height: 1.25rem;
|
height: 1.2em;
|
||||||
|
margin-left: 1rem;
|
||||||
|
accent-color: var(--checkbox-accent);
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.routine-item img {
|
.routine-item img {
|
||||||
width: 2.5rem;
|
width: 36px;
|
||||||
height: 2.5rem;
|
height: 36px;
|
||||||
border-radius: 6px;
|
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-right: 0.7rem;
|
||||||
|
background: var(--list-image-bg);
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.routine-item .name {
|
.name {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.routine-item .value {
|
.value {
|
||||||
min-width: 60px;
|
min-width: 60px;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|||||||
@@ -22,7 +22,18 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="items-list">
|
<div v-else class="items-list">
|
||||||
<div v-for="(item, idx) in items" :key="item.id || `new-${idx}`" class="item-row">
|
<div
|
||||||
|
v-for="(item, idx) in items"
|
||||||
|
:key="item.id || `new-${idx}`"
|
||||||
|
class="item-row"
|
||||||
|
:class="{ 'drag-over': dragOverIdx === idx, dragging: draggingIdx === idx }"
|
||||||
|
draggable="true"
|
||||||
|
@dragstart="(e) => onDragStart(e, idx)"
|
||||||
|
@dragover.prevent="(e) => onDragOver(e, idx)"
|
||||||
|
@drop.prevent="onDrop(idx)"
|
||||||
|
@dragend="onDragEnd"
|
||||||
|
>
|
||||||
|
<span class="drag-handle" title="Drag to reorder">⠿</span>
|
||||||
<div class="item-left">
|
<div class="item-left">
|
||||||
<img
|
<img
|
||||||
v-if="item.image_url"
|
v-if="item.image_url"
|
||||||
@@ -150,6 +161,8 @@ const loading = ref(false)
|
|||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
const localImageFile = ref<File | null>(null)
|
const localImageFile = ref<File | null>(null)
|
||||||
const removedItemIds = ref<string[]>([])
|
const removedItemIds = ref<string[]>([])
|
||||||
|
const draggingIdx = ref<number | null>(null)
|
||||||
|
const dragOverIdx = ref<number | null>(null)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (isEdit.value && props.id) {
|
if (isEdit.value && props.id) {
|
||||||
@@ -320,6 +333,36 @@ function cancelEditItem() {
|
|||||||
newItemLocalFile.value = null
|
newItemLocalFile.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onDragStart(e: DragEvent, idx: number) {
|
||||||
|
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move'
|
||||||
|
cancelEditItem()
|
||||||
|
draggingIdx.value = idx
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragOver(e: DragEvent, idx: number) {
|
||||||
|
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'
|
||||||
|
if (draggingIdx.value === null || draggingIdx.value === idx) return
|
||||||
|
dragOverIdx.value = idx
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDrop(idx: number) {
|
||||||
|
if (draggingIdx.value === null || draggingIdx.value === idx) {
|
||||||
|
draggingIdx.value = null
|
||||||
|
dragOverIdx.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const moved = items.value.splice(draggingIdx.value, 1)[0]
|
||||||
|
items.value.splice(idx, 0, moved)
|
||||||
|
normalizeItemOrder()
|
||||||
|
draggingIdx.value = null
|
||||||
|
dragOverIdx.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragEnd() {
|
||||||
|
draggingIdx.value = null
|
||||||
|
dragOverIdx.value = null
|
||||||
|
}
|
||||||
|
|
||||||
async function uploadImage(file: File): Promise<string> {
|
async function uploadImage(file: File): Promise<string> {
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append('file', file)
|
formData.append('file', file)
|
||||||
@@ -473,6 +516,29 @@ function handleCancel() {
|
|||||||
border: 1px solid var(--form-input-border, #e6e6e6);
|
border: 1px solid var(--form-input-border, #e6e6e6);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 0.55rem 0.7rem;
|
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-right: 0.45rem;
|
||||||
|
user-select: none;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-handle:active {
|
||||||
|
cursor: grabbing;
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-left {
|
.item-left {
|
||||||
@@ -480,6 +546,7 @@ function handleCancel() {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
gap: 0.6rem;
|
gap: 0.6rem;
|
||||||
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-thumb {
|
.item-thumb {
|
||||||
|
|||||||
Reference in New Issue
Block a user