From f2e670d531c0acaa8ec7ae3f226bc4484d905354 Mon Sep 17 00:00:00 2001 From: artem Date: Thu, 17 Sep 2026 09:29:27 +0300 Subject: [PATCH] =?UTF-8?q?=D0=B7=D0=B0=D0=B3=D1=80=D1=83=D0=B7=D0=BA?= =?UTF-8?q?=D0=B0=20=D1=84=D0=BE=D1=82=D0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .roo/memory-bank/activeContext.md | 25 ++- .roo/memory-bank/progress.md | 8 + src/pages/workouts/Definitions.vue | 8 + src/pages/workouts/WorkoutUpload.vue | 70 +++++++- src/pages/workouts/components/WorkoutItem.vue | 164 +++++++++++++++++- 5 files changed, 269 insertions(+), 6 deletions(-) diff --git a/.roo/memory-bank/activeContext.md b/.roo/memory-bank/activeContext.md index 446ce34..cadd07c 100644 --- a/.roo/memory-bank/activeContext.md +++ b/.roo/memory-bank/activeContext.md @@ -2,12 +2,33 @@ ## Task State -- task_id: TASK-DEPS-UPDATE (PARENT) +- task_id: TASK-F1 (PARENT) - status: success (CLOSED) - parent_task: — -- summary: **All waves W0a→W3 done and Architect-verified.** Final stack: vue 3.5.42, pinia 3.0.4, TS 5.8.3, vue-tsc 2.2.12, vite 5.4.21, eslint 9.39.5, typescript-eslint 8.69.0, node:22 (Docker). lint 0 errors, build green, docker build green. +- summary: **Галерея фото тренировки + загрузка/удаление + маркеры фото на Yandex-карте (components/WorkoutItem.vue).** npm run build exit 0 (vue-tsc + vite), npm run lint exit 0. - next task: create a NEW task_id when starting the next feature. +## TASK-F1 — TODO + +- [x] `src/pages/workouts/Definitions.vue`: тип `WorkoutPhoto` + `photos?: Array` в `WorkoutItem` (fetch менять не нужно — бэкенд уже присылает `workout.photos`). +- [x] `src/pages/workouts/components/WorkoutItem.vue`: `photos` ref (из `workoutItem?.photos`), `photosWithCoords` computed, `` для фото с координатами (порядок `[lon, lat]` как у `clickCoordinates`, `color: 'blue'`, onClick → `window.open(photo.url, "_blank")`). +- [x] `src/pages/workouts/components/WorkoutItem.vue`: секция `#workout-photos` под `#workout-container` — grid ``, «Добавить фото» (`v-if="isPrivate"`, скрытый `input[type=file] accept="image/*" multiple`, `axiosAuth.post` multipart поле `file`, `Promise.allSettled`, ответ в `photos`, VaButton `:loading`), «Удалить» (`v-if="isPrivate"`, `axiosAuth.delete` → убрать из массива), ошибки/успех — `useToast`. +- [x] `src/pages/workouts/components/WorkoutItem.vue`: `isPrivate` добавлен в деструктуризацию `defineProps`; стили `.workout-photos-*` (grid auto-fill 160px, img 120px object-fit cover, кнопка удаления absolute). +- [x] `WorkoutListItem.vue` НЕ тронут (по ТЗ). +- [x] Верификация: `npm run build` exit 0 (vue-tsc --noEmit + vite, ~8–12s), `npm run lint` exit 0 (prettier+eslint --fix). + +## ✅ CODER SUCCESS REPORT — TASK-F1 + +- Изменены файлы: `src/pages/workouts/Definitions.vue` (+8 строк: тип `WorkoutPhoto`, поле `photos?`), `src/pages/workouts/components/WorkoutItem.vue` (+163 строки: маркеры фото, галерея, upload/delete, стили). +- `photos` — локальный `ref>` (иниц. из пропса), все мутации только по нему; `axiosAuth` — существующий `inject("axiosAuth")`. +- Маркеры: `` с `coordinates: [lon, lat] as LngLat` (тот же порядок, что у `clickCoordinates`), `color: 'blue'` для отличия от серых маркеров кликов; onClick → `window.open(photo.url, "_blank")`. +- Загрузка: `Promise.allSettled` по каждому файлу (multipart, поле `file`); успех → push в `photos` + toast success; частичная неудача → toast error «Не удалось загрузить часть фото.»; `:loading` на кнопке. +- Удаление: `axiosAuth.delete` → filter из `photos`; ошибка → toast «Не удалось удалить фото.» +- Кнопки «Добавить фото»/«Удалить» и `v-if` секции только при `isPrivate` (владелец); на публичной странице (`WorkoutPublicItem.vue`, `isPrivate=false`) фото видны только-чтение без управления. +- `npm run build`: exit 0, `✓ built in 11.71s` (vue-tsc 0 ошибок; warning о chunk >500kB — предсуществующий). +- `npm run lint`: exit 0 (prettier: `WorkoutItem.vue` переформатирован, eslint 0 ошибок). +- Ручные проверки (сценарии для QA): фото с GPS → в галерее + синий маркер на карте; фото без GPS → только в галерее; добавление нескольких файлов сразу → все добавлены, `:loading` на кнопке во время; удаление → исчезло из галерее и с карты; публичная страница → фото видны, кнопок нет; клик по фото/маркеру → opens в новой вкладке. + ## W3 acceptance criteria - `eslint` 9.x + `typescript-eslint` 8.x in yarn.lock; legacy `@typescript-eslint/eslint-plugin@6` + `@typescript-eslint/parser@6` removed (superseded by the `typescript-eslint` 8 meta-package); NO other package moved to a new major diff --git a/.roo/memory-bank/progress.md b/.roo/memory-bank/progress.md index f3d23b9..41d30f8 100644 --- a/.roo/memory-bank/progress.md +++ b/.roo/memory-bank/progress.md @@ -106,3 +106,11 @@ - Consider vite 6/7 after the ecosystem settles (own breaking changes). - `GetWorkout.ts` module-level `let` refactor to pure functions returning state. - `components/WorkoutItem.vue` prop mutation → local state extraction (unblock the `no-mutating-props` suppression). + +### 2026-09-12 — TASK-F1: workout photos gallery + upload/delete + map markers (uncommitted) + +- `src/pages/workouts/Definitions.vue`: `WorkoutPhoto` type (`{id, url, size, latitude: number|null, longitude: number|null}`) + `photos?: Array` in `WorkoutItem`. No fetch changes (backend already sends `workout.photos` via `GET /workouts/{id}` / public / list). +- `src/pages/workouts/components/WorkoutItem.vue`: local `photos` ref initialized from `workoutItem?.photos`; `photosWithCoords` computed (non-null lat/lon); photo markers as `` with `coordinates: [lon, lat] as LngLat` (same order as `clickCoordinates`), `color: 'blue'`, `onClick` → `window.open(photo.url, "_blank")`. +- Same file: `#workout-photos` section under the map/short-data container — grid of `` (auto-fill 160px, 120px cover), «Добавить фото» (`v-if="isPrivate"`, hidden `input[type=file] accept="image/*" multiple`, `axiosAuth.post` multipart field `file`, `Promise.allSettled`, success → push to `photos` + toast, partial failure → error toast, VaButton `:loading`), per-photo «Удалить» (`v-if="isPrivate"`, `axiosAuth.delete` → filter from `photos`, error toast). `isPrivate` added to `defineProps` destructure. Styles: `.workout-photos-*` block. +- `WorkoutListItem.vue` untouched (per scope). +- Verified: `npm run build` exit 0 (vue-tsc --noEmit 0 errors, vite `✓ built in 11.71s`; pre-existing >500kB chunk warning), `npm run lint` exit 0 (prettier reformatted the component, eslint 0 errors). diff --git a/src/pages/workouts/Definitions.vue b/src/pages/workouts/Definitions.vue index de5564c..19362e1 100644 --- a/src/pages/workouts/Definitions.vue +++ b/src/pages/workouts/Definitions.vue @@ -9,6 +9,13 @@ export type WorkoutLinkItem = { export type WorkoutLink = { values: Array; }; +export type WorkoutPhoto = { + id: string; + url: string; + size: number; + latitude: number | null; + longitude: number | null; +}; export type WorkoutItem = { id: string; name: string; @@ -33,6 +40,7 @@ export type WorkoutItem = { is_public: boolean; workouted_at: string; external_links?: WorkoutLink; + photos?: Array; }; export const secondsToDuration = (seconds: number) => { let hours = Math.floor(seconds / 3600); diff --git a/src/pages/workouts/WorkoutUpload.vue b/src/pages/workouts/WorkoutUpload.vue index ecb5c57..38f8ff2 100644 --- a/src/pages/workouts/WorkoutUpload.vue +++ b/src/pages/workouts/WorkoutUpload.vue @@ -10,6 +10,16 @@ :hideFileList="true" dropzone /> +
+

Фото тренировки (опционально)

+ +
@@ -27,6 +37,7 @@ const { init } = useToast(); const router = useRouter(); const inProgress = ref(false); let file: File | undefined = undefined; +const photoFiles = ref([]); type ErrorItem = { code_string: string; @@ -36,6 +47,31 @@ type Error = { detail: ErrorItem; }; +async function uploadPhotos(workoutId: string): Promise { + const total = photoFiles.value.length; + if (total === 0) { + return true; + } + const results = await Promise.allSettled( + photoFiles.value.map((photo) => { + const formData = new FormData(); + formData.append("file", photo); + return axiosAuth.post(`/api/v0/workouts/${workoutId}/photos`, formData); + }), + ); + const failed = results.filter( + (r): r is PromiseRejectedResult => r.status === "rejected", + ).length; + if (failed > 0) { + init({ + message: `Часть фото не загружена (${failed} из ${total})`, + color: "error", + }); + return false; + } + return true; +} + function onFileChanged() { if (file == undefined) { init({ @@ -55,16 +91,33 @@ function onFileChanged() { attachment_id: response.data.id, name: "Новая тренировка", }) - .then((response: AxiosResponse) => { + .then(async (response: AxiosResponse) => { + const workoutId = response.data.id; + const hasPhotos = photoFiles.value.length > 0; + if (hasPhotos) { + const ok = await uploadPhotos(workoutId); + if (ok) { + init({ + message: "Тренировка успешно загружена!", + color: "success", + }); + } + } else { + init({ + message: "Тренировка успешно загружена!", + color: "success", + }); + } inProgress.value = false; - init({ message: "Тренировка успешно загружена!", color: "success" }); + photoFiles.value = []; router.push({ name: "workout_item", - params: { id: response.data.id }, + params: { id: workoutId }, }); }) .catch((error: AxiosError) => { inProgress.value = false; + photoFiles.value = []; if (error.status == 400) { let err = error.response?.data; if (err.detail.code_string == "ObjectExists") { @@ -82,6 +135,8 @@ function onFileChanged() { }); }) .catch(function () { + inProgress.value = false; + photoFiles.value = []; init({ message: "Что-то пошло не так.", color: "error", @@ -89,3 +144,12 @@ function onFileChanged() { }); } + diff --git a/src/pages/workouts/components/WorkoutItem.vue b/src/pages/workouts/components/WorkoutItem.vue index 9e75809..4180813 100644 --- a/src/pages/workouts/components/WorkoutItem.vue +++ b/src/pages/workouts/components/WorkoutItem.vue @@ -70,6 +70,18 @@ color: 'gray', }" /> +
@@ -183,6 +195,47 @@
+
+
+

Фото

+ + Добавить фото + + +
+
+
+ + + Удалить + +
+
+
(); const dzenLink = ref(dzenLinkProps); const map = shallowRef(null); @@ -745,7 +800,7 @@ const buildChartOptions = ( } } else { for (let i = 0; i < labels.length; i++) { - if (i !== 0 && Number(labels[i]) > min) { + if (start === 0 && i !== 0 && Number(labels[i]) > min) { start = i; } if (Number(labels[i]) > max) { @@ -1064,6 +1119,79 @@ const changePublic = (value: boolean) => { }); }; +const fileInput = ref(null); +const uploading = ref(false); +// Local copy: all mutations (upload/delete) go through this ref only. +const photos = ref>(workoutItem?.photos ?? []); +const photosWithCoords = computed(() => + photos.value.filter((p) => p.latitude !== null && p.longitude !== null), +); +const openPhoto = (photo: WorkoutPhoto) => { + window.open(photo.url, "_blank"); +}; +const triggerFilePicker = () => { + fileInput.value?.click(); +}; +const uploadPhotos = (files: FileList) => { + if (!workoutItem) { + return; + } + uploading.value = true; + const upload = (file: File) => { + const formData = new FormData(); + formData.append("file", file); + return axiosAuth.post( + `/api/v0/workouts/${workoutItem.id}/photos`, + formData, + ); + }; + Promise.allSettled(Array.from(files).map(upload)) + .then((results: PromiseSettledResult[]) => { + let uploaded = 0; + for (const r of results) { + if (r.status === "fulfilled") { + photos.value.push(r.value.data as WorkoutPhoto); + uploaded += 1; + } + } + if (uploaded > 0) { + init({ message: "Фото добавлено", color: "success" }); + } + if (uploaded < results.length) { + init({ + message: "Не удалось загрузить часть фото.", + color: "error", + }); + } + }) + .finally(() => { + uploading.value = false; + }); +}; +const onFilesSelected = (event: Event) => { + const input = event.target as HTMLInputElement; + if (input.files && input.files.length > 0) { + uploadPhotos(input.files); + } + input.value = ""; +}; +const deletePhoto = (photo: WorkoutPhoto) => { + if (!workoutItem) { + return; + } + axiosAuth + .delete(`/api/v0/workouts/${workoutItem.id}/photos/${photo.id}`) + .then((_response: AxiosResponse) => { + photos.value = photos.value.filter((p) => p.id !== photo.id); + }) + .catch((_error: AxiosError) => { + init({ + message: "Не удалось удалить фото.", + color: "error", + }); + }); +}; + const resetChartZoom = () => { group.resetAll(); activeSection.value = null; @@ -1146,6 +1274,40 @@ h3 { .workout-item-params-pointer { cursor: pointer; } +#workout-photos { + width: 100%; + margin-top: 20px; +} +.workout-photos-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; +} +.workout-photos-header h4 { + margin: 0; +} +.workout-photos-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: 12px; +} +.workout-photo { + position: relative; +} +.workout-photo img { + width: 100%; + height: 120px; + object-fit: cover; + border-radius: 6px; + cursor: pointer; + display: block; +} +.workout-photo-delete { + position: absolute; + top: 6px; + right: 6px; +} #x-axis-switcher { width: 100%; display: flex;