загрузка фото
Gitea Actions Demo / build_and_push (push) Successful in 49s
Details
Gitea Actions Demo / build_and_push (push) Successful in 49s
Details
This commit is contained in:
parent
0bbc90cb5c
commit
f2e670d531
|
|
@ -2,12 +2,33 @@
|
||||||
|
|
||||||
## Task State
|
## Task State
|
||||||
|
|
||||||
- task_id: TASK-DEPS-UPDATE (PARENT)
|
- task_id: TASK-F1 (PARENT)
|
||||||
- status: success (CLOSED)
|
- status: success (CLOSED)
|
||||||
- parent_task: —
|
- 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.
|
- next task: create a NEW task_id when starting the next feature.
|
||||||
|
|
||||||
|
## TASK-F1 — TODO
|
||||||
|
|
||||||
|
- [x] `src/pages/workouts/Definitions.vue`: тип `WorkoutPhoto` + `photos?: Array<WorkoutPhoto>` в `WorkoutItem` (fetch менять не нужно — бэкенд уже присылает `workout.photos`).
|
||||||
|
- [x] `src/pages/workouts/components/WorkoutItem.vue`: `photos` ref (из `workoutItem?.photos`), `photosWithCoords` computed, `<yandex-map-default-marker>` для фото с координатами (порядок `[lon, lat]` как у `clickCoordinates`, `color: 'blue'`, onClick → `window.open(photo.url, "_blank")`).
|
||||||
|
- [x] `src/pages/workouts/components/WorkoutItem.vue`: секция `#workout-photos` под `#workout-container` — grid `<img :src="photo.url">`, «Добавить фото» (`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<Array<WorkoutPhoto>>` (иниц. из пропса), все мутации только по нему; `axiosAuth` — существующий `inject("axiosAuth")`.
|
||||||
|
- Маркеры: `<yandex-map-default-marker>` с `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
|
## 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
|
- `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
|
||||||
|
|
|
||||||
|
|
@ -106,3 +106,11 @@
|
||||||
- Consider vite 6/7 after the ecosystem settles (own breaking changes).
|
- Consider vite 6/7 after the ecosystem settles (own breaking changes).
|
||||||
- `GetWorkout.ts` module-level `let` refactor to pure functions returning state.
|
- `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).
|
- `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<WorkoutPhoto>` 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 `<yandex-map-default-marker>` 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 `<img :src="photo.url">` (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).
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,13 @@ export type WorkoutLinkItem = {
|
||||||
export type WorkoutLink = {
|
export type WorkoutLink = {
|
||||||
values: Array<WorkoutLinkItem>;
|
values: Array<WorkoutLinkItem>;
|
||||||
};
|
};
|
||||||
|
export type WorkoutPhoto = {
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
size: number;
|
||||||
|
latitude: number | null;
|
||||||
|
longitude: number | null;
|
||||||
|
};
|
||||||
export type WorkoutItem = {
|
export type WorkoutItem = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -33,6 +40,7 @@ export type WorkoutItem = {
|
||||||
is_public: boolean;
|
is_public: boolean;
|
||||||
workouted_at: string;
|
workouted_at: string;
|
||||||
external_links?: WorkoutLink;
|
external_links?: WorkoutLink;
|
||||||
|
photos?: Array<WorkoutPhoto>;
|
||||||
};
|
};
|
||||||
export const secondsToDuration = (seconds: number) => {
|
export const secondsToDuration = (seconds: number) => {
|
||||||
let hours = Math.floor(seconds / 3600);
|
let hours = Math.floor(seconds / 3600);
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,16 @@
|
||||||
:hideFileList="true"
|
:hideFileList="true"
|
||||||
dropzone
|
dropzone
|
||||||
/>
|
/>
|
||||||
|
<div class="photo-upload" v-if="!inProgress">
|
||||||
|
<h2 class="photo-upload__title">Фото тренировки (опционально)</h2>
|
||||||
|
<VaFileUpload
|
||||||
|
v-model="photoFiles"
|
||||||
|
file-types="png,jpg,jpeg,webp,heic"
|
||||||
|
type="list"
|
||||||
|
color="#F4F6F8"
|
||||||
|
dropzone
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<VaInnerLoading loading :size="60"> </VaInnerLoading>
|
<VaInnerLoading loading :size="60"> </VaInnerLoading>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -27,6 +37,7 @@ const { init } = useToast();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const inProgress = ref(false);
|
const inProgress = ref(false);
|
||||||
let file: File | undefined = undefined;
|
let file: File | undefined = undefined;
|
||||||
|
const photoFiles = ref<File[]>([]);
|
||||||
|
|
||||||
type ErrorItem = {
|
type ErrorItem = {
|
||||||
code_string: string;
|
code_string: string;
|
||||||
|
|
@ -36,6 +47,31 @@ type Error = {
|
||||||
detail: ErrorItem;
|
detail: ErrorItem;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
async function uploadPhotos(workoutId: string): Promise<boolean> {
|
||||||
|
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() {
|
function onFileChanged() {
|
||||||
if (file == undefined) {
|
if (file == undefined) {
|
||||||
init({
|
init({
|
||||||
|
|
@ -55,16 +91,33 @@ function onFileChanged() {
|
||||||
attachment_id: response.data.id,
|
attachment_id: response.data.id,
|
||||||
name: "Новая тренировка",
|
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;
|
inProgress.value = false;
|
||||||
init({ message: "Тренировка успешно загружена!", color: "success" });
|
photoFiles.value = [];
|
||||||
router.push({
|
router.push({
|
||||||
name: "workout_item",
|
name: "workout_item",
|
||||||
params: { id: response.data.id },
|
params: { id: workoutId },
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch((error: AxiosError) => {
|
.catch((error: AxiosError) => {
|
||||||
inProgress.value = false;
|
inProgress.value = false;
|
||||||
|
photoFiles.value = [];
|
||||||
if (error.status == 400) {
|
if (error.status == 400) {
|
||||||
let err = <Error>error.response?.data;
|
let err = <Error>error.response?.data;
|
||||||
if (err.detail.code_string == "ObjectExists") {
|
if (err.detail.code_string == "ObjectExists") {
|
||||||
|
|
@ -82,6 +135,8 @@ function onFileChanged() {
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch(function () {
|
.catch(function () {
|
||||||
|
inProgress.value = false;
|
||||||
|
photoFiles.value = [];
|
||||||
init({
|
init({
|
||||||
message: "Что-то пошло не так.",
|
message: "Что-то пошло не так.",
|
||||||
color: "error",
|
color: "error",
|
||||||
|
|
@ -89,3 +144,12 @@ function onFileChanged() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
<style scoped>
|
||||||
|
.photo-upload {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
.photo-upload__title {
|
||||||
|
font-size: 18px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,18 @@
|
||||||
color: 'gray',
|
color: 'gray',
|
||||||
}"
|
}"
|
||||||
/>
|
/>
|
||||||
|
<yandex-map-default-marker
|
||||||
|
v-for="photo in photosWithCoords"
|
||||||
|
:key="'photo-' + photo.id"
|
||||||
|
:settings="{
|
||||||
|
coordinates: [
|
||||||
|
photo.longitude as number,
|
||||||
|
photo.latitude as number,
|
||||||
|
] as LngLat,
|
||||||
|
color: 'blue',
|
||||||
|
onClick: (_: unknown, __: unknown) => openPhoto(photo),
|
||||||
|
}"
|
||||||
|
/>
|
||||||
</yandex-map>
|
</yandex-map>
|
||||||
</div>
|
</div>
|
||||||
<div id="workout-short-data">
|
<div id="workout-short-data">
|
||||||
|
|
@ -183,6 +195,47 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
id="workout-photos"
|
||||||
|
v-if="workoutItem && (photos.length > 0 || isPrivate)"
|
||||||
|
>
|
||||||
|
<div class="workout-photos-header">
|
||||||
|
<h4>Фото</h4>
|
||||||
|
<VaButton
|
||||||
|
v-if="isPrivate"
|
||||||
|
preset="secondary"
|
||||||
|
color="primary"
|
||||||
|
size="small"
|
||||||
|
:loading="uploading"
|
||||||
|
@click="triggerFilePicker"
|
||||||
|
>
|
||||||
|
Добавить фото
|
||||||
|
</VaButton>
|
||||||
|
<input
|
||||||
|
ref="fileInput"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
multiple
|
||||||
|
hidden
|
||||||
|
@change="onFilesSelected"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="workout-photos-grid" v-if="photos.length > 0">
|
||||||
|
<div class="workout-photo" v-for="photo in photos" :key="photo.id">
|
||||||
|
<img :src="photo.url" :alt="photo.id" @click="openPhoto(photo)" />
|
||||||
|
<VaButton
|
||||||
|
v-if="isPrivate"
|
||||||
|
preset="danger"
|
||||||
|
flat
|
||||||
|
size="small"
|
||||||
|
class="workout-photo-delete"
|
||||||
|
@click="deletePhoto(photo)"
|
||||||
|
>
|
||||||
|
Удалить
|
||||||
|
</VaButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div id="x-axis-switcher">
|
<div id="x-axis-switcher">
|
||||||
<div class="x-axis-buttons">
|
<div class="x-axis-buttons">
|
||||||
<VaButton
|
<VaButton
|
||||||
|
|
@ -342,6 +395,7 @@ import {
|
||||||
formatTime,
|
formatTime,
|
||||||
ChartData,
|
ChartData,
|
||||||
ChartDataByMetric,
|
ChartDataByMetric,
|
||||||
|
WorkoutPhoto,
|
||||||
} from "../Definitions.vue";
|
} from "../Definitions.vue";
|
||||||
|
|
||||||
type XAxisMode = "time" | "distance";
|
type XAxisMode = "time" | "distance";
|
||||||
|
|
@ -373,6 +427,7 @@ const {
|
||||||
lineCoordinates,
|
lineCoordinates,
|
||||||
distances,
|
distances,
|
||||||
dzenLink: dzenLinkProps,
|
dzenLink: dzenLinkProps,
|
||||||
|
isPrivate,
|
||||||
} = defineProps<Props>();
|
} = defineProps<Props>();
|
||||||
const dzenLink = ref(dzenLinkProps);
|
const dzenLink = ref(dzenLinkProps);
|
||||||
const map = shallowRef<null | YMap>(null);
|
const map = shallowRef<null | YMap>(null);
|
||||||
|
|
@ -745,7 +800,7 @@ const buildChartOptions = (
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
for (let i = 0; i < labels.length; i++) {
|
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;
|
start = i;
|
||||||
}
|
}
|
||||||
if (Number(labels[i]) > max) {
|
if (Number(labels[i]) > max) {
|
||||||
|
|
@ -1064,6 +1119,79 @@ const changePublic = (value: boolean) => {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fileInput = ref<HTMLInputElement | null>(null);
|
||||||
|
const uploading = ref(false);
|
||||||
|
// Local copy: all mutations (upload/delete) go through this ref only.
|
||||||
|
const photos = ref<Array<WorkoutPhoto>>(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<AxiosResponse>[]) => {
|
||||||
|
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 = () => {
|
const resetChartZoom = () => {
|
||||||
group.resetAll();
|
group.resetAll();
|
||||||
activeSection.value = null;
|
activeSection.value = null;
|
||||||
|
|
@ -1146,6 +1274,40 @@ h3 {
|
||||||
.workout-item-params-pointer {
|
.workout-item-params-pointer {
|
||||||
cursor: 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 {
|
#x-axis-switcher {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue