fix html
Gitea Actions Demo / build_and_push (push) Successful in 58s
Details
Gitea Actions Demo / build_and_push (push) Successful in 58s
Details
This commit is contained in:
parent
b17b67dc90
commit
de2728ccb9
|
|
@ -90,6 +90,7 @@ out
|
||||||
# Nuxt.js build / generate output
|
# Nuxt.js build / generate output
|
||||||
.nuxt
|
.nuxt
|
||||||
dist
|
dist
|
||||||
|
dist-server
|
||||||
|
|
||||||
# Gatsby files
|
# Gatsby files
|
||||||
.cache/
|
.cache/
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,14 @@
|
||||||
|
|
||||||
## Task State
|
## Task State
|
||||||
|
|
||||||
- task_id: TASK-SECTION-DIST-FIX
|
- task_id: TASK-SSR-EXPLORE-CARDS
|
||||||
- status: success
|
- status: success
|
||||||
- summary: **Добавлена строка «Дистанция» в «Данные участка» + drag-zoom теперь персистится при смене вкладки (selectedRange вместо null в onZoomComplete). 3 passed.**
|
- summary: **SSR `/explore` теперь рендерит карточки 1:1 с клиентом: `<section class="news-feed">` → `<article class="news-item">` (`.news-item-header`→`<h3><a>`, `ul.metadata` 9 условных `<li>` в порядке клиента, `.image-container`-`<a>` с background-image). Хэндлер + `distInt` в `server/index.ts`; добавлен отсутствовавший `tsconfig.server.json` (`yarn server:build` теперь работает).**
|
||||||
- next task: —
|
- next task: —
|
||||||
|
- plan: `plans/TASK-SSR-EXPLORE-CARDS.md`
|
||||||
|
|
||||||
## ✅ CODER SUCCESS REPORT — TASK-INTEG-SECTION (+TASK-SECTION-BUGS)
|
## ✅ CODER SUCCESS REPORT — TASK-INTEG-SECTION (+TASK-SECTION-BUGS)
|
||||||
|
|
||||||
- `../integration/tests/section-duration.spec.ts` — 3 теста (duration row, zoom persist across tabs, reset on all tabs), self-contained (stable owner `itest-section-owner`, API-seed, Chart.js via `canvas.__vueParentComponent.exposed.chart`).
|
- `../integration/tests/section-duration.spec.ts` — 3 теста (duration row, zoom persist across tabs, reset on all tabs), self-contained (stable owner `itest-section-owner`, API-seed, Chart.js via `canvas.__vueParentComponent.exposed.chart`).
|
||||||
- **Bug 1 фикс** (`WorkoutItem.vue` `setChartRef` ~1155): ref пушится сразу (без guard `r.chart`), финализация (`collectCharts` + `applySelectedRangeZoom`) через retry `nextTick` (до 20 попыток) — дождётся асинхронной инициализации Chart.js. `nextTick` добавлен в импорт из "vue" (~строка 440).
|
- **Bug 1 фикс** (`WorkoutItem.vue` `setChartRef` ~1155): ref пушится сразу (без guard `r.chart`), финализация (`collectCharts` + `applySelectedRangeZoom`) через retry `nextTick` (до 20 попыток) — дождётся асинхронной инициализации Chart.js. `nextTick` добавлен в импорт из "vue" (~строка 440).
|
||||||
- **Bug 2 фикс** (`ChartGroup.ts` `resetAll` ~125): после `resetZoom(chart)` явно `x.min=undefined; x.max=undefined; chart.update("none")` (broadcastZoom писал min/max напрямую, минуя плагин). Дополнительно: на время цикла `resetAll` установлена `this.broadcasting=true` (try/finally) — `resetZoom` триггерит `onZoomComplete`, который иначе повторно разносит ещё-зумированный диапазон на остальные чарты; guard `isBroadcasting()` отбрасывает эти колбэки.
|
- **Bug 2 фикс** (`ChartGroup.ts` `resetAll` ~125): после `resetZoom(chart)` явно `x.min=undefined; x.max=undefined; chart.update("none")` (broadcastZoom писал min/max напрямую, минуя плагин). Дополнительно: на время цикла `resetAll` установлена `this.broadcasting=true` (try/finally) — `resetZoom` триггерит `onZoomComplete`, который иначе повторно разносит ещё-зумированный диапазон на остальные чарты; guard `isBroadcasting()` отбрасывает эти колбэки.
|
||||||
|
|
@ -20,11 +22,13 @@
|
||||||
**Scope:** 1 новый файл — `../integration/tests/section-duration.spec.ts` + запуск на стеке. Не трогать `src/`.
|
**Scope:** 1 новый файл — `../integration/tests/section-duration.spec.ts` + запуск на стеке. Не трогать `src/`.
|
||||||
|
|
||||||
**Что тестируется (3 UX-требования, компонент `src/pages/workouts/components/WorkoutItem.vue`, страница `workout_item`):**
|
**Что тестируется (3 UX-требования, компонент `src/pages/workouts/components/WorkoutItem.vue`, страница `workout_item`):**
|
||||||
|
|
||||||
1. Клик по графику (выделение участка) → в блоке `.area-data` появляется строка «Продолжительность».
|
1. Клик по графику (выделение участка) → в блоке `.area-data` появляется строка «Продолжительность».
|
||||||
2. Клик по вкладке «Дистанция» → тот же участок остаётся в zoom (не весь путь).
|
2. Клик по вкладке «Дистанция» → тот же участок остаётся в zoom (не весь путь).
|
||||||
3. Клик по reset → участок сброшен; после смена вкладки → весь путь (reset на всех вкладках).
|
3. Клик по reset → участок сброшен; после смена вкладки → весь путь (reset на всех вкладках).
|
||||||
|
|
||||||
**Контракты (проверено Архитектором):**
|
**Контракты (проверено Архитектором):**
|
||||||
|
|
||||||
- Детальная страница (приватная, с графиками + вкладками): маршрут `workout_item` → URL `{FRONTEND}/workouts/workouts/{id}` (родитель `workouts` + вложенный `workouts/:id`). Навигация `waitUntil: "domcontentloaded"` (Yandex map держит соединения).
|
- Детальная страница (приватная, с графиками + вкладками): маршрут `workout_item` → URL `{FRONTEND}/workouts/workouts/{id}` (родитель `workouts` + вложенный `workouts/:id`). Навигация `waitUntil: "domcontentloaded"` (Yandex map держит соединения).
|
||||||
- Seed через API (чисто, без UI-dedup): `POST {BACKEND}/api/v0/signup` (200) ИЛИ `POST /api/v0/signin` (fallback на повторном прогоне) → token; `POST /api/v0/attachment/upload` (FormData поле `file`, Bearer) → `{id}`; `POST /api/v0/workouts` `{attachment_id, name}` → `{id}` (400 `ObjectExists` → взять id из `GET /api/v0/workouts`[0]).
|
- Seed через API (чисто, без UI-dedup): `POST {BACKEND}/api/v0/signup` (200) ИЛИ `POST /api/v0/signin` (fallback на повторном прогоне) → token; `POST /api/v0/attachment/upload` (FormData поле `file`, Bearer) → `{id}`; `POST /api/v0/workouts` `{attachment_id, name}` → `{id}` (400 `ObjectExists` → взять id из `GET /api/v0/workouts`[0]).
|
||||||
- Файл: `../../backend/tests/fitness_tracker_data_parsing-main/20240507053735.fit` (тот же, что в `workout-upload-flow.spec.ts`).
|
- Файл: `../../backend/tests/fitness_tracker_data_parsing-main/20240507053735.fit` (тот же, что в `workout-upload-flow.spec.ts`).
|
||||||
|
|
@ -34,6 +38,7 @@
|
||||||
- Кнопки вкладок: по тексту «Время» и «Дистанция» (VaButton). Reset: `#zoom-botton`. Блок данных участка: `.area-data` (заголовок «Данные участка»), строка «Продолжительность».
|
- Кнопки вкладок: по тексту «Время» и «Дистанция» (VaButton). Reset: `#zoom-botton`. Блок данных участка: `.area-data` (заголовок «Данные участка»), строка «Продолжительность».
|
||||||
|
|
||||||
**Структура спе (self-contained, repeatable):**
|
**Структура спе (self-contained, repeatable):**
|
||||||
|
|
||||||
- `beforeAll`: проверить стек (frontend `<500`, backend `/api/v0/html_test/login`==200), проверить наличие fixture-файла; seed stable owner (`itest-section-owner@example.com`, idempotent signup/signin) + его workout (upload attachment + create; на 400 — id из списка).
|
- `beforeAll`: проверить стек (frontend `<500`, backend `/api/v0/html_test/login`==200), проверить наличие fixture-файла; seed stable owner (`itest-section-owner@example.com`, idempotent signup/signin) + его workout (upload attachment + create; на 400 — id из списка).
|
||||||
- 3 независимых `test`'а (каждый: `authedPage(token)` → `openWorkoutDetail(id)` → `expect` canvas visible):
|
- 3 независимых `test`'а (каждый: `authedPage(token)` → `openWorkoutDetail(id)` → `expect` canvas visible):
|
||||||
1. «duration row on section select»: canvas-click → `expect .area-data` visible → `expect` текст «Продолжительность» в `.area-data`.
|
1. «duration row on section select»: canvas-click → `expect .area-data` visible → `expect` текст «Продолжительность» в `.area-data`.
|
||||||
|
|
@ -41,12 +46,14 @@
|
||||||
3. «reset clears section on all tabs»: canvas-click (zoom) → assert zoomed → click `#zoom-botton` (reset) → assert NOT zoomed → click «Дистанция» → assert NOT zoomed (весь путь).
|
3. «reset clears section on all tabs»: canvas-click (zoom) → assert zoomed → click `#zoom-botton` (reset) → assert NOT zoomed → click «Дистанция» → assert NOT zoomed (весь путь).
|
||||||
|
|
||||||
**Замечания:**
|
**Замечания:**
|
||||||
|
|
||||||
- Не `expect` внутри `page.evaluate` — только `return` данных наружу.
|
- Не `expect` внутри `page.evaluate` — только `return` данных наружу.
|
||||||
- `authedPage`: goto → set `localStorage.token` → goto (паттерн из `workout-upload-flow.spec.ts`).
|
- `authedPage`: goto → set `localStorage.token` → goto (паттерн из `workout-upload-flow.spec.ts`).
|
||||||
- Node ≥20: запуск через nvm `export PATH="$HOME/.nvm/versions/node/v22.14.0/bin:$PATH"`.
|
- Node ≥20: запуск через nvm `export PATH="$HOME/.nvm/versions/node/v22.14.0/bin:$PATH"`.
|
||||||
- `yarn up` (`docker compose up -d --build`) → ждать backend: `until curl -sf http://localhost:8000/api/v0/html_test/login >/dev/null; do sleep 2; done` → `npx playwright test tests/section-duration.spec.ts`.
|
- `yarn up` (`docker compose up -d --build`) → ждать backend: `until curl -sf http://localhost:8000/api/v0/html_test/login >/dev/null; do sleep 2; done` → `npx playwright test tests/section-duration.spec.ts`.
|
||||||
|
|
||||||
**Acceptance criteria:**
|
**Acceptance criteria:**
|
||||||
|
|
||||||
1. `../integration/tests/section-duration.spec.ts` создан.
|
1. `../integration/tests/section-duration.spec.ts` создан.
|
||||||
2. `npx playwright test tests/section-duration.spec.ts` → **3 passed** на запущенном стеке.
|
2. `npx playwright test tests/section-duration.spec.ts` → **3 passed** на запущенном стеке.
|
||||||
3. Не сломать существующие спе (опционально: `npx playwright test --list`).
|
3. Не сломать существующие спе (опционально: `npx playwright test --list`).
|
||||||
|
|
@ -289,6 +296,19 @@
|
||||||
- `src/components/app-layout-navigation/AppLayoutNavigation.vue`: `const globalStore = useGlobalStore()`; `traverse(globalStore.isAuthenticated ? authRoutes : publicRoutes)` внутри `findRouteName` (вызывается в `items` computed → реactivity сохранена).
|
- `src/components/app-layout-navigation/AppLayoutNavigation.vue`: `const globalStore = useGlobalStore()`; `traverse(globalStore.isAuthenticated ? authRoutes : publicRoutes)` внутри `findRouteName` (вызывается в `items` computed → реactivity сохранена).
|
||||||
- Verified: `yarn lint` exit 0, `yarn build` exit 0 (vue-tsc --noEmit 0 ошибок, vite ✓ 7.07s).
|
- Verified: `yarn lint` exit 0, `yarn build` exit 0 (vue-tsc --noEmit 0 ошибок, vite ✓ 7.07s).
|
||||||
|
|
||||||
|
## ✅ CODER SUCCESS REPORT — TASK-SSR-EXPLORE-CARDS
|
||||||
|
|
||||||
|
- `server/index.ts`: хэндлер `GET /explore` (~строки 135–228) переписан — `content` теперь `<section class="news-feed">` с `<article class="news-item">` на каждый workout: `.news-item-header` → `<h3><a href="/public/workouts/{id}">`, `ul.metadata` с 9 условными `<li>` в порядке клиента (Дата, Фото, Скорость, Пульс, Расстояние, Продолжительность, Каденс, Мощность, Ссылки), `.image-container` — `<a>` c `background-image` (только если `w.attachment?.url`). Имя/ссылки экранированы `escapeHtml`. SEO-мета (`title`/`description`/`canonical`/`jsonLd`) и `catch → 500` сохранены без изменений.
|
||||||
|
- `server/index.ts`: новый форматтер `distInt(m) = Math.round(m/1000)` (~строка 52, после `formatSpeed`) — расстояние в ленте целыми км (совпадение с клиентским `distConvert`); `formatDistance` (toFixed(1)) не тронут — используется в `/` и detail.
|
||||||
|
- `server/index.ts`: импорт `WorkoutItem` из `./api` добавлен.
|
||||||
|
- **`tsconfig.server.json` создан (был отсутствует)** — `yarn server:build` (`tsc -p tsconfig.server.json`) до этого падал «config file missing»; теперь `commonjs`/`outDir: dist-server`/`strict`, как ожидал `server:start` (`node dist-server/index.js`).
|
||||||
|
- `.gitignore`: добавлен `dist-server` (артефакт server:build).
|
||||||
|
- Verified: `yarn server:build` → Done (0 ошибок); `yarn build` (vue-tsc --noEmit + vite) → ✓ built in 6.58s (0 ошибок).
|
||||||
|
- Verified HTML (SSR dev, реальный production-backend): `curl http://localhost:3001/explore` — 3× `news-item`, 3× `metadata`, 3× `image-container`, 1× `news-feed`, `<a href="/public/workouts/{id}"`, метки Дата/Фото/Скорость/Пульс/Расстояние/Продолжительность/Каденс/Мощность. «Ссылки» корректно отсутствует: у всех 3 workout'ов ленты `external_links.values` пустые (v-if — 1:1 с клиентом).
|
||||||
|
- Verified browser (MCP): `browser_navigate http://localhost:3001/explore` → snapshot: 3 `article` c `<h3>`-заголовками-ссылками на `/public/workouts/{id}` + `list` c «Дата: …»; title «Публичные велотренировки — Cycle Rider». Консоль: только 3 pre-existing 404 на статические ассеты (в dev SSR `dist/` не сервится — в prod это делает nginx; app-ошибок нет).
|
||||||
|
- Known (вне scope): `yarn lint` (только `./src/**/*`) — 73 pre-existing errors, src не трогался.
|
||||||
|
- Note: в процессе проверка `git stash`/`pop` столкнулась с конкурентным обновлением `activeContext.md` (TASK-SECTION-DIST-FIX); состояние восстановлено, конфликтных потерь нет.
|
||||||
|
|
||||||
## История (закрытые задачи)
|
## История (закрытые задачи)
|
||||||
|
|
||||||
- **TASK-HIDE-TRACK-4**: success — `trimTrack` в `WorkoutPublicItem.vue` при `hide_start_end`; `hide_start_end` в типе `WorkoutItem` (Definitions.vue). Verified: `yarn build` 0. (Пред. active: TASK-TEST-PUBLIC → в очереди TASK-TEST-PROTECTED.)
|
- **TASK-HIDE-TRACK-4**: success — `trimTrack` в `WorkoutPublicItem.vue` при `hide_start_end`; `hide_start_end` в типе `WorkoutItem` (Definitions.vue). Verified: `yarn build` 0. (Пред. active: TASK-TEST-PUBLIC → в очереди TASK-TEST-PROTECTED.)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,17 @@
|
||||||
# Progress — Strava Frontend
|
# Progress — Strava Frontend
|
||||||
|
|
||||||
|
## 2026-09-26 — TASK-SECTION-REZOOM-LOOP: фикс бесконечного цикла re-render (CPU-шум)
|
||||||
|
|
||||||
|
- `src/pages/workouts/components/WorkoutItem.vue`:
|
||||||
|
- Новый флаг `let pendingRezoom = false;` (рядом с `chartRefs`, ~строка 526).
|
||||||
|
- `switchXAxis`: после `chartRefs.length = 0` добавлено `pendingRezoom = true`.
|
||||||
|
- `setChartRef` / `tryFinalize`: `applySelectedRangeZoom()` вызывается
|
||||||
|
только если `pendingRezoom === true` (затем флаг сбрасывается в false).
|
||||||
|
Устранён цикл: клик → areaAvgData → re-render → setChartRef →
|
||||||
|
applySelectedRangeZoom → broadcastZoom → update("none") →
|
||||||
|
reactivity → re-render → setChartRef → ... (бесконечно).
|
||||||
|
- Verified: `yarn build` exit 0; `npx playwright test tests/section-duration.spec.ts` → 3 passed (13.7s).
|
||||||
|
|
||||||
## 2026-09-26 — TASK-SECTION-DIST-FIX: строка «Дистанция» + drag-zoom персистится при смене вкладки
|
## 2026-09-26 — TASK-SECTION-DIST-FIX: строка «Дистанция» + drag-zoom персистится при смене вкладки
|
||||||
|
|
||||||
- `src/pages/workouts/components/WorkoutItem.vue`:
|
- `src/pages/workouts/components/WorkoutItem.vue`:
|
||||||
|
|
@ -332,3 +344,13 @@
|
||||||
|
|
||||||
- `../backend/tests/test_api_workout.py`: добавлен `test_workout_hide_start_end` (PATCH hide_start_end False→True→False + GET-верификация)
|
- `../backend/tests/test_api_workout.py`: добавлен `test_workout_hide_start_end` (PATCH hide_start_end False→True→False + GET-верификация)
|
||||||
- Результат: `python -m pytest tests/test_api_workout.py -v` → **4 passed (5.13s)**
|
- Результат: `python -m pytest tests/test_api_workout.py -v` → **4 passed (5.13s)**
|
||||||
|
|
||||||
|
## TASK-SSR-EXPLORE-CARDS (2026-09-27)
|
||||||
|
|
||||||
|
- `server/index.ts`: хэндлер `GET /explore` — `content` = `<section class="news-feed">` c `<article class="news-item">` (`.news-item-header`→`<h3><a href="/public/workouts/{id}">`, `ul.metadata` 9 условных `<li>` в порядке клиента, `.image-container`-`<a>` c `background-image`); SEO-мета и catch→500 без изменений; импорт `WorkoutItem` из `./api`.
|
||||||
|
- `server/index.ts`: новый форматтер `distInt(m)=Math.round(m/1000)` (после `formatSpeed`, ~строка 52); `formatDistance` не тронут.
|
||||||
|
- `tsconfig.server.json` (новый файл): `module commonjs`, `outDir dist-server`, `strict`, `rootDir server`, `include server/**/*.ts` — скрипт `server:build` ссылался на него, файла не было.
|
||||||
|
- `.gitignore`: добавлена строка `dist-server`.
|
||||||
|
- Verified: `yarn server:build` → Done (0 ошибок); `yarn build` → ✓ built in 6.58s (0 ошибок, pre-existing >500kB chunk warning).
|
||||||
|
- Verified HTML: `curl http://localhost:3001/explore` (SSR dev, production backend) — `news-feed`×1, `news-item`×3, `metadata`×3, `image-container`×3, `<a href="/public/workouts/{id}"`, метки Дата/Фото/Скорость/Пульс/Расстояние/Продолжительность/Каденс/Мощность; «Ссылки» нет (у всех 3 workout'ов `external_links.values` пустые — корректно, v-if).
|
||||||
|
- Verified browser (MCP): 3 карточки (`article` c `<h3>`-ссылкой + `list` «Дата: …»), title SSR-заголовка; console — только 3 pre-existing 404 на статические ассеты (dev SSR не сервит `dist/`; в prod — nginx), app-ошибок нет.
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import express, { Request, Response } from "express";
|
import express, { Request, Response } from "express";
|
||||||
import { getPublicWorkouts, getPublicWorkout } from "./api";
|
import { getPublicWorkouts, getPublicWorkout, WorkoutItem } from "./api";
|
||||||
import { renderTemplate, getAssetTags, SeoMeta } from "./template";
|
import { renderTemplate, getAssetTags, SeoMeta } from "./template";
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
@ -49,6 +49,10 @@ function formatSpeed(ms: number): string {
|
||||||
return Math.round(ms * 3.6).toString();
|
return Math.round(ms * 3.6).toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function distInt(m: number): string {
|
||||||
|
return Math.round(m / 1000).toString();
|
||||||
|
}
|
||||||
|
|
||||||
function getToken(req: Request): string | null {
|
function getToken(req: Request): string | null {
|
||||||
const cookieHeader = req.headers.cookie;
|
const cookieHeader = req.headers.cookie;
|
||||||
if (!cookieHeader) return null;
|
if (!cookieHeader) return null;
|
||||||
|
|
@ -132,24 +136,78 @@ app.get("/explore", async (_req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const workouts = await getPublicWorkouts();
|
const workouts = await getPublicWorkouts();
|
||||||
const list = workouts
|
const list = workouts
|
||||||
.map(
|
.map((w: WorkoutItem) => {
|
||||||
(w) =>
|
const meta: string[] = [];
|
||||||
` <li>\n <a href="/public/workouts/${w.id}">${escapeHtml(
|
if (w.workouted_at) {
|
||||||
w.name,
|
meta.push(
|
||||||
)}</a>\n — ${formatDate(w.workouted_at)}, ${formatDistance(
|
` <li><span>Дата:</span> ${formatDate(w.workouted_at)}</li>`,
|
||||||
w.distantion,
|
);
|
||||||
)} км, ${formatSpeed(w.speed)} км/ч, ${
|
}
|
||||||
w.heart_rate
|
if (w.photos && w.photos.length > 0) {
|
||||||
} уд/мин\n </li>`,
|
meta.push(` <li><span>Фото:</span> ${w.photos.length}</li>`);
|
||||||
)
|
}
|
||||||
|
if (w.speed) {
|
||||||
|
meta.push(
|
||||||
|
` <li><span>Скорость:</span> ${formatSpeed(
|
||||||
|
w.speed,
|
||||||
|
)} км/ч </li>`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (w.heart_rate) {
|
||||||
|
meta.push(
|
||||||
|
` <li><span>Пульс:</span> ${w.heart_rate} уд /мин </li>`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (w.distantion) {
|
||||||
|
meta.push(
|
||||||
|
` <li><span>Расстояние:</span> ${distInt(
|
||||||
|
w.distantion,
|
||||||
|
)} км </li>`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (w.duraion_sec) {
|
||||||
|
meta.push(
|
||||||
|
` <li><span>Продолжительность:</span> ${formatDuration(
|
||||||
|
w.duraion_sec,
|
||||||
|
)}</li>`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (w.cadence) {
|
||||||
|
meta.push(` <li><span>Каденс:</span> ${w.cadence} об/мин </li>`);
|
||||||
|
}
|
||||||
|
if (w.power) {
|
||||||
|
meta.push(
|
||||||
|
` <li><span>Мощность:</span> ${Math.round(w.power)} Вт </li>`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (w.external_links && w.external_links.values.length > 0) {
|
||||||
|
meta.push(
|
||||||
|
` <li><span>Ссылки:</span> <a href="${escapeHtml(
|
||||||
|
w.external_links.values[0].value,
|
||||||
|
)}" target="_blank">Дзен</a></li>`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const imageUrl = w.attachment?.url;
|
||||||
|
const image = imageUrl
|
||||||
|
? `\n <a class="image-container" href="/public/workouts/${
|
||||||
|
w.id
|
||||||
|
}" style="background-image:url(${escapeHtml(imageUrl)})"></a>`
|
||||||
|
: "";
|
||||||
|
return ` <article class="news-item">
|
||||||
|
<div class="news-item-header"><h3><a href="/public/workouts/${
|
||||||
|
w.id
|
||||||
|
}">${escapeHtml(w.name)}</a></h3></div>
|
||||||
|
<ul class="metadata">
|
||||||
|
${meta.join("\n")}
|
||||||
|
</ul>${image}
|
||||||
|
</article>`;
|
||||||
|
})
|
||||||
.join("\n");
|
.join("\n");
|
||||||
|
|
||||||
const content = `
|
const content = `
|
||||||
<h1>Публичные тренировки</h1>
|
<section class="news-feed">
|
||||||
<p>Лента велотренировок от сообщества.</p>
|
|
||||||
<ul>
|
|
||||||
${list}
|
${list}
|
||||||
</ul>`;
|
</section>`;
|
||||||
|
|
||||||
const meta: SeoMeta = {
|
const meta: SeoMeta = {
|
||||||
title: "Публичные велотренировки — Cycle Rider",
|
title: "Публичные велотренировки — Cycle Rider",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue