ssr
Gitea Actions Demo / build_and_push (push) Successful in 1m10s
Details
Gitea Actions Demo / build_and_push (push) Successful in 1m10s
Details
This commit is contained in:
parent
c193cc55a9
commit
8e0730aaeb
|
|
@ -0,0 +1,71 @@
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
const API_BASE = process.env.VITE_APP_API_URL || "https://cycle-rider.ru";
|
||||||
|
|
||||||
|
export const api = axios.create({
|
||||||
|
baseURL: API_BASE,
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface WorkoutItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
created_by: string;
|
||||||
|
created_at: string;
|
||||||
|
description: string;
|
||||||
|
cadence: number;
|
||||||
|
heart_rate: number;
|
||||||
|
max_cadence: number;
|
||||||
|
max_heart_rate: number;
|
||||||
|
temperature: number;
|
||||||
|
speed: number;
|
||||||
|
power: number;
|
||||||
|
max_speed: number;
|
||||||
|
max_power: number;
|
||||||
|
duraion_sec: number;
|
||||||
|
distantion: number;
|
||||||
|
attachment: { url: string } | null;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
is_public: boolean;
|
||||||
|
workouted_at: string;
|
||||||
|
external_links?: { values: Array<{ type: string; value: string }> };
|
||||||
|
photos?: Array<{
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
size: number;
|
||||||
|
latitude: number | null;
|
||||||
|
longitude: number | null;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkoutListResponse {
|
||||||
|
results: WorkoutItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkoutDetailResponse {
|
||||||
|
workout: WorkoutItem;
|
||||||
|
results: Array<{
|
||||||
|
timestamp: number;
|
||||||
|
longitude: number;
|
||||||
|
latitude: number;
|
||||||
|
elevation: number | null;
|
||||||
|
power: number | null;
|
||||||
|
heart_rate: number | null;
|
||||||
|
speed: number | null;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPublicWorkouts(): Promise<WorkoutItem[]> {
|
||||||
|
const res = await api.get<WorkoutListResponse>("/api/v0/public/workouts");
|
||||||
|
return res.data.results;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPublicWorkout(
|
||||||
|
id: string,
|
||||||
|
): Promise<WorkoutDetailResponse> {
|
||||||
|
const res = await api.get<WorkoutDetailResponse>(
|
||||||
|
`/api/v0/public/workouts/${id}`,
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,286 @@
|
||||||
|
import express, { Request, Response } from "express";
|
||||||
|
import { getPublicWorkouts, getPublicWorkout } from "./api";
|
||||||
|
import { renderTemplate, getAssetTags, SeoMeta } from "./template";
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const PORT = process.env.SSR_PORT || 3001;
|
||||||
|
const BASE_URL = process.env.SSR_BASE_URL || "https://cycle-rider.ru";
|
||||||
|
|
||||||
|
app.disable("x-powered-by");
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
|
||||||
|
function getAssetTagsSafe(): string {
|
||||||
|
try {
|
||||||
|
return getAssetTags();
|
||||||
|
} catch {
|
||||||
|
return '<script type="module" src="/src/main.ts"></script>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text: string): string {
|
||||||
|
return text.replace(/</g, "<").replace(/>/g, ">");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso: string): string {
|
||||||
|
const d = new Date(iso);
|
||||||
|
const dd = String(d.getDate()).padStart(2, "0");
|
||||||
|
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
||||||
|
const yyyy = d.getFullYear();
|
||||||
|
return `${dd}.${mm}.${yyyy}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(sec: number): string {
|
||||||
|
const h = Math.floor(sec / 3600);
|
||||||
|
const m = Math.floor((sec % 3600) / 60);
|
||||||
|
return `${h} ч. ${m} мин.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDistance(meters: number): string {
|
||||||
|
return (meters / 1000).toFixed(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSpeed(ms: number): string {
|
||||||
|
return Math.round(ms * 3.6).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getToken(req: Request): string | null {
|
||||||
|
const cookieHeader = req.headers.cookie;
|
||||||
|
if (!cookieHeader) return null;
|
||||||
|
const match = cookieHeader.match(/(?:^|;\s*)token=([^;]*)/);
|
||||||
|
return match ? decodeURIComponent(match[1]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Routes ---
|
||||||
|
|
||||||
|
// GET / — Landing page (SEO)
|
||||||
|
app.get("/", async (_req: Request, res: Response) => {
|
||||||
|
const token = getToken(_req);
|
||||||
|
if (token) {
|
||||||
|
res.redirect(302, "/explore");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let workoutsHtml: string;
|
||||||
|
try {
|
||||||
|
const workouts = await getPublicWorkouts();
|
||||||
|
const top5 = workouts.slice(0, 5);
|
||||||
|
workoutsHtml = top5
|
||||||
|
.map(
|
||||||
|
(w) =>
|
||||||
|
` <li><a href="/public/workouts/${w.id}">${escapeHtml(w.name)}</a> — ${formatDate(w.workouted_at)}, ${formatDistance(w.distantion)} км</li>`,
|
||||||
|
)
|
||||||
|
.join("\n");
|
||||||
|
} catch {
|
||||||
|
workoutsHtml = " <li>Загрузка…</li>";
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = `
|
||||||
|
<h1>Cycle Rider — анализ велотренировок</h1>
|
||||||
|
<p>Платформа для анализа велотренировок: мощность, пульс, скорость, каденс. Загрузите FIT/GPX файлы и получите детальную аналитику.</p>
|
||||||
|
<h2>Последние тренировки</h2>
|
||||||
|
<ul>
|
||||||
|
${workoutsHtml}
|
||||||
|
</ul>
|
||||||
|
<h2>Возможности</h2>
|
||||||
|
<ul>
|
||||||
|
<li>Нормализованная мощность (NP) и FTP</li>
|
||||||
|
<li>Пульсовые зоны и скорость</li>
|
||||||
|
<li>Каденс и дистанция</li>
|
||||||
|
<li>Профиль высоты</li>
|
||||||
|
</ul>`;
|
||||||
|
|
||||||
|
const meta: SeoMeta = {
|
||||||
|
title: "Cycle Rider — Анализ велотренировок: мощность, пульс, скорость",
|
||||||
|
description:
|
||||||
|
"Анализируй велотренировки: мощность (FTP, NP), пульс, скорость, каденс, зоны мощности. Загрузка FIT/GPX. Cycle Rider.",
|
||||||
|
canonicalUrl: `${BASE_URL}/`,
|
||||||
|
jsonLd: {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "WebApplication",
|
||||||
|
name: "Cycle Rider",
|
||||||
|
description: "Платформа для анализа велотренировок",
|
||||||
|
url: `${BASE_URL}/`,
|
||||||
|
applicationCategory: "HealthApplication",
|
||||||
|
operatingSystem: "Web",
|
||||||
|
offers: {
|
||||||
|
"@type": "Offer",
|
||||||
|
price: "0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
res.send(renderTemplate({ meta, content, assetTags: getAssetTagsSafe() }));
|
||||||
|
} catch {
|
||||||
|
res.status(500).send("Internal Server Error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /explore — Public feed
|
||||||
|
app.get("/explore", async (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const workouts = await getPublicWorkouts();
|
||||||
|
const list = workouts
|
||||||
|
.map(
|
||||||
|
(w) =>
|
||||||
|
` <li>\n <a href="/public/workouts/${w.id}">${escapeHtml(w.name)}</a>\n — ${formatDate(w.workouted_at)}, ${formatDistance(w.distantion)} км, ${formatSpeed(w.speed)} км/ч, ${w.heart_rate} уд/мин\n </li>`,
|
||||||
|
)
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
const content = `
|
||||||
|
<h1>Публичные тренировки</h1>
|
||||||
|
<p>Лента велотренировок от сообщества.</p>
|
||||||
|
<ul>
|
||||||
|
${list}
|
||||||
|
</ul>`;
|
||||||
|
|
||||||
|
const meta: SeoMeta = {
|
||||||
|
title: "Публичные велотренировки — Cycle Rider",
|
||||||
|
description:
|
||||||
|
"Лента публичных велотренировок: скорость, пульс, мощность, расстояние. Cycle Rider.",
|
||||||
|
canonicalUrl: `${BASE_URL}/explore`,
|
||||||
|
jsonLd: {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "WebPage",
|
||||||
|
name: "Публичные велотренировки",
|
||||||
|
url: `${BASE_URL}/explore`,
|
||||||
|
description:
|
||||||
|
"Лента публичных велотренировок: скорость, пульс, мощность, расстояние.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
res.send(renderTemplate({ meta, content, assetTags: getAssetTagsSafe() }));
|
||||||
|
} catch {
|
||||||
|
res.status(500).send("Internal Server Error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /public/workouts/:id — Workout detail
|
||||||
|
app.get("/public/workouts/:id", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const detail = await getPublicWorkout(req.params.id);
|
||||||
|
const w = detail.workout;
|
||||||
|
|
||||||
|
const content = `
|
||||||
|
<h1>${escapeHtml(w.name)}</h1>
|
||||||
|
<p>${escapeHtml(w.description)}</p>
|
||||||
|
<ul class="workout-metrics">
|
||||||
|
<li>Дата: ${formatDate(w.workouted_at)}</li>
|
||||||
|
<li>Расстояние: ${formatDistance(w.distantion)} км</li>
|
||||||
|
<li>Средняя скорость: ${formatSpeed(w.speed)} км/ч</li>
|
||||||
|
<li>Макс. скорость: ${formatSpeed(w.max_speed)} км/ч</li>
|
||||||
|
<li>Средний пульс: ${w.heart_rate} уд/мин</li>
|
||||||
|
<li>Макс. пульс: ${w.max_heart_rate} уд/мин</li>
|
||||||
|
<li>Средняя мощность: ${w.power} Вт</li>
|
||||||
|
<li>Макс. мощность: ${w.max_power} Вт</li>
|
||||||
|
<li>Каденс: ${w.cadence} об/мин</li>
|
||||||
|
<li>Длительность: ${formatDuration(w.duraion_sec)}</li>
|
||||||
|
</ul>`;
|
||||||
|
|
||||||
|
const desc =
|
||||||
|
w.description.slice(0, 150) + (w.description.length > 150 ? "…" : "");
|
||||||
|
const metrics = `${formatDistance(w.distantion)} км, ${formatSpeed(w.speed)} км/ч, пульс ${w.heart_rate}, мощность ${w.power} Вт`;
|
||||||
|
|
||||||
|
const meta: SeoMeta = {
|
||||||
|
title: `${w.name} — Cycle Rider`,
|
||||||
|
description: `${desc} ${metrics}`,
|
||||||
|
canonicalUrl: `${BASE_URL}/public/workouts/${w.id}`,
|
||||||
|
jsonLd: {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "SportsActivity",
|
||||||
|
name: w.name,
|
||||||
|
description: desc,
|
||||||
|
url: `${BASE_URL}/public/workouts/${w.id}`,
|
||||||
|
startDate: w.workouted_at,
|
||||||
|
sport: "Cycling",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
res.send(renderTemplate({ meta, content, assetTags: getAssetTagsSafe() }));
|
||||||
|
} catch (err: unknown) {
|
||||||
|
// Check for 404 from API
|
||||||
|
if (
|
||||||
|
err &&
|
||||||
|
typeof err === "object" &&
|
||||||
|
"response" in err &&
|
||||||
|
(err as { response?: { status?: number } }).response?.status === 404
|
||||||
|
) {
|
||||||
|
res.status(404).send("Not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.status(500).send("Internal Server Error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /routes — Routes page (minimal)
|
||||||
|
app.get("/routes", async (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const content = `
|
||||||
|
<h1>Конструктор маршрутов</h1>
|
||||||
|
<p>Создайте свой велосипедный маршрут на карте. Добавляйте точки, рассчитывайте дистанцию и экспортируйте в GPX.</p>`;
|
||||||
|
|
||||||
|
const meta: SeoMeta = {
|
||||||
|
title: "Конструктор веломаршрутов — Cycle Rider",
|
||||||
|
description:
|
||||||
|
"Создайте велосипедный маршрут на карте, экспортируйте в GPX. Cycle Rider.",
|
||||||
|
canonicalUrl: `${BASE_URL}/routes`,
|
||||||
|
};
|
||||||
|
|
||||||
|
res.send(renderTemplate({ meta, content, assetTags: getAssetTagsSafe() }));
|
||||||
|
} catch {
|
||||||
|
res.status(500).send("Internal Server Error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /sitemap.xml
|
||||||
|
app.get("/sitemap.xml", async (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const workouts = await getPublicWorkouts();
|
||||||
|
|
||||||
|
const staticUrls = [
|
||||||
|
`<url><loc>${BASE_URL}/</loc><changefreq>daily</changefreq><priority>1.0</priority></url>`,
|
||||||
|
`<url><loc>${BASE_URL}/explore</loc><changefreq>daily</changefreq><priority>0.9</priority></url>`,
|
||||||
|
`<url><loc>${BASE_URL}/routes</loc><changefreq>weekly</changefreq><priority>0.5</priority></url>`,
|
||||||
|
];
|
||||||
|
|
||||||
|
const workoutUrls = workouts.map((w) => {
|
||||||
|
const date = new Date(w.workouted_at).toISOString().split("T")[0];
|
||||||
|
return `<url><loc>${BASE_URL}/public/workouts/${w.id}</loc><lastmod>${date}</lastmod><changefreq>monthly</changefreq><priority>0.7</priority></url>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const xml = `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n ${[
|
||||||
|
...staticUrls,
|
||||||
|
...workoutUrls,
|
||||||
|
].join("\n ")}\n</urlset>`;
|
||||||
|
|
||||||
|
res.set("Content-Type", "application/xml; charset=utf-8");
|
||||||
|
res.send(xml);
|
||||||
|
} catch {
|
||||||
|
res.status(500).send("Internal Server Error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /robots.txt
|
||||||
|
app.get("/robots.txt", (_req: Request, res: Response) => {
|
||||||
|
const text = `User-agent: *
|
||||||
|
Allow: /
|
||||||
|
Disallow: /auth/
|
||||||
|
Disallow: /workouts/
|
||||||
|
Disallow: /preferences/
|
||||||
|
Disallow: /admin/
|
||||||
|
|
||||||
|
Sitemap: ${BASE_URL}/sitemap.xml
|
||||||
|
`;
|
||||||
|
res.set("Content-Type", "text/plain; charset=utf-8");
|
||||||
|
res.send(text);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Health check
|
||||||
|
app.get("/health", (_req: Request, res: Response) => {
|
||||||
|
res.json({ status: "ok" });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`SSR server listening on :${PORT}`);
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
export interface SeoMeta {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
canonicalUrl: string;
|
||||||
|
ogImage?: string;
|
||||||
|
jsonLd?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TemplateOptions {
|
||||||
|
meta: SeoMeta;
|
||||||
|
content: string; // HTML content to inject inside <div id="app">
|
||||||
|
assetTags: string; // <script>/<link> tags from built dist/index.html
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read built dist/index.html and extract <script> and <link rel="stylesheet"> tags.
|
||||||
|
* This ensures we always reference the correct hashed asset filenames.
|
||||||
|
*/
|
||||||
|
export function getAssetTags(): string {
|
||||||
|
const distIndex = path.resolve(__dirname, "../dist/index.html");
|
||||||
|
const html = fs.readFileSync(distIndex, "utf-8");
|
||||||
|
const scriptMatch = html.match(/<script[^>]*src="[^"]*"[^>]*><\/script>/g);
|
||||||
|
const linkMatch = html.match(/<link[^>]*rel="stylesheet"[^>]*>/g);
|
||||||
|
return [...(linkMatch || []), ...(scriptMatch || [])].join("\n ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderTemplate(options: TemplateOptions): string {
|
||||||
|
const { meta, content, assetTags } = options;
|
||||||
|
const jsonLdScript = meta.jsonLd
|
||||||
|
? `\n <script type="application/ld+json">${JSON.stringify(meta.jsonLd)}</script>`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="yandex-verification" content="81ff150ccc5ab8c6" />
|
||||||
|
<title>${meta.title}</title>
|
||||||
|
<meta name="description" content="${meta.description}" />
|
||||||
|
<link rel="canonical" href="${meta.canonicalUrl}" />
|
||||||
|
<meta property="og:title" content="${meta.title}" />
|
||||||
|
<meta property="og:description" content="${meta.description}" />
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:url" content="${meta.canonicalUrl}" />
|
||||||
|
${meta.ogImage ? `<meta property="og:image" content="${meta.ogImage}" />` : ""}
|
||||||
|
<link rel="icon" href="/favicon.ico" />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap" rel="stylesheet" />
|
||||||
|
${assetTags}
|
||||||
|
${jsonLdScript}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
${content}
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue