feat: русский UI, версии в футере, даты и устойчивость загрузки API
Some checks failed
CI / build-and-test (pull_request) Has been cancelled

- API: дата старта всегда YYYY-MM-DD; фронт: parseRaceDate без двойного T00:00:00
- GET /health с version из package.json; Vite define __FRONTEND_VERSION__
- Футер с версиями клиента/сервера (BEM), сетка app-shell на три ряда
- AbortController для карточки старта; ретраи GET при 502–504 и понятные ошибки шлюза
- Русские подписи навигации/страниц, lang=ru, без английских фраз в интерфейсе
This commit is contained in:
Vaka.pro
2026-04-08 00:40:03 +03:00
parent fc995ed07d
commit 42ee36d0a2
22 changed files with 251 additions and 77 deletions

View File

@@ -4,7 +4,7 @@
*/ */
export interface RaceRow { export interface RaceRow {
id: string; id: string;
race_date: string; race_date: string | Date;
title: string; title: string;
distance_km: string; distance_km: string;
status: string | null; status: string | null;
@@ -43,11 +43,23 @@ function toISOString(value: Date | string): string {
return value instanceof Date ? value.toISOString() : String(value); return value instanceof Date ? value.toISOString() : String(value);
} }
/** DATE column may arrive as string or Date; API always exposes YYYY-MM-DD for the calendar day. */
function raceDateToApiValue(value: string | Date): string {
if (typeof value === "string") {
const m = value.match(/^(\d{4}-\d{2}-\d{2})/);
return m ? m[1]! : value;
}
const y = value.getFullYear();
const mo = String(value.getMonth() + 1).padStart(2, "0");
const day = String(value.getDate()).padStart(2, "0");
return `${y}-${mo}-${day}`;
}
/** Convert a DB row to the API DTO (camelCase). */ /** Convert a DB row to the API DTO (camelCase). */
export function rowToDto(row: RaceRow): RaceDto { export function rowToDto(row: RaceRow): RaceDto {
return { return {
id: row.id, id: row.id,
date: row.race_date, date: raceDateToApiValue(row.race_date),
title: row.title, title: row.title,
distanceKm: parseFloat(row.distance_km), distanceKm: parseFloat(row.distance_km),
status: row.status, status: row.status,

View File

@@ -1,10 +1,11 @@
import { Router, Request, Response } from "express"; import { Router, Request, Response } from "express";
import { checkDbConnection } from "../db"; import { checkDbConnection } from "../db";
import { getBackendVersion } from "../version";
const router = Router(); const router = Router();
router.get("/health", (_req: Request, res: Response) => { router.get("/health", (_req: Request, res: Response) => {
res.json({ status: "ok" }); res.json({ status: "ok", version: getBackendVersion() });
}); });
router.get("/ready", async (_req: Request, res: Response) => { router.get("/ready", async (_req: Request, res: Response) => {

14
backend/src/version.ts Normal file
View File

@@ -0,0 +1,14 @@
import fs from "fs";
import path from "path";
let cached: string | null = null;
export function getBackendVersion(): string {
if (cached) {
return cached;
}
const pkgPath = path.join(__dirname, "..", "package.json");
const raw = fs.readFileSync(pkgPath, "utf-8");
cached = (JSON.parse(raw) as { version: string }).version;
return cached;
}

View File

@@ -8,6 +8,8 @@ const app = createApp();
test("GET /health returns ok", async () => { test("GET /health returns ok", async () => {
const res = await request(app).get("/health").expect(200); const res = await request(app).get("/health").expect(200);
assert.equal(res.body.status, "ok"); assert.equal(res.body.status, "ok");
assert.equal(typeof res.body.version, "string");
assert.ok(res.body.version.length > 0);
}); });
test("GET /ready succeeds with mock database", async () => { test("GET /ready succeeds with mock database", async () => {

View File

@@ -1,9 +1,9 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="ru">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Calendar Run</title> <title>Календарь стартов</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -42,7 +42,26 @@ function normalizeApiCode(value: string | undefined): ApiErrorCode {
return "unknown_error"; return "unknown_error";
} }
function isGatewayStatus(status: number): boolean {
return status === 502 || status === 503 || status === 504;
}
function hasStructuredApiError(payload: unknown): payload is ApiErrorPayload {
if (payload === null || typeof payload !== "object" || Array.isArray(payload)) {
return false;
}
return typeof (payload as ApiErrorPayload).error === "string";
}
export function toApiError(status: number, payload: unknown): ApiError { export function toApiError(status: number, payload: unknown): ApiError {
if (isGatewayStatus(status) && !hasStructuredApiError(payload)) {
return new ApiError({
code: "network_error",
status,
message: "Сервер временно недоступен. Попробуйте обновить страницу.",
});
}
const maybePayload = payload as ApiErrorPayload; const maybePayload = payload as ApiErrorPayload;
const code = normalizeApiCode(maybePayload?.error); const code = normalizeApiCode(maybePayload?.error);
const details = Array.isArray(maybePayload?.details) const details = Array.isArray(maybePayload?.details)

View File

@@ -0,0 +1,10 @@
import { requestJson } from "./http";
export type HealthResponse = {
status: string;
version: string;
};
export async function getHealth(init?: RequestInit): Promise<HealthResponse> {
return requestJson<HealthResponse>("/health", init);
}

View File

@@ -20,7 +20,20 @@ async function parseResponseBody(response: Response): Promise<unknown> {
} }
} }
const GATEWAY_RETRY_STATUSES = new Set([502, 503, 504]);
function delay(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
export async function requestJson<T>(path: string, init?: RequestInit): Promise<T> { export async function requestJson<T>(path: string, init?: RequestInit): Promise<T> {
const method = (init?.method ?? "GET").toUpperCase();
const idempotent = method === "GET" || method === "HEAD";
const maxAttempts = idempotent ? 3 : 1;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try { try {
const response = await fetch(buildUrl(path), { const response = await fetch(buildUrl(path), {
...init, ...init,
@@ -37,6 +50,11 @@ export async function requestJson<T>(path: string, init?: RequestInit): Promise<
const payload = await parseResponseBody(response); const payload = await parseResponseBody(response);
if (!response.ok) { if (!response.ok) {
const retryable = idempotent && GATEWAY_RETRY_STATUSES.has(response.status) && attempt < maxAttempts;
if (retryable) {
await delay(80 * attempt);
continue;
}
throw toApiError(response.status, payload); throw toApiError(response.status, payload);
} }
@@ -45,7 +63,14 @@ export async function requestJson<T>(path: string, init?: RequestInit): Promise<
if (error instanceof ApiError) { if (error instanceof ApiError) {
throw error; throw error;
} }
if (error instanceof DOMException && error.name === "AbortError") {
throw error;
}
const retryable = idempotent && attempt < maxAttempts;
if (retryable) {
await delay(80 * attempt);
continue;
}
throw new ApiError({ throw new ApiError({
code: "network_error", code: "network_error",
status: null, status: null,
@@ -53,3 +78,10 @@ export async function requestJson<T>(path: string, init?: RequestInit): Promise<
}); });
} }
} }
throw new ApiError({
code: "network_error",
status: null,
message: "Не удалось связаться с сервером.",
});
}

View File

@@ -1,3 +1,5 @@
export type { CreateRacePayload, Race, RacesQuery, RaceStatus, UpdateRacePayload } from "./types"; export type { CreateRacePayload, Race, RacesQuery, RaceStatus, UpdateRacePayload } from "./types";
export { ApiError, getApiErrorMessage } from "./errors"; export { ApiError, getApiErrorMessage } from "./errors";
export type { HealthResponse } from "./health";
export { getHealth } from "./health";
export { getRaceById, getRaces, createRace, updateRace, deleteRace } from "./races"; export { getRaceById, getRaces, createRace, updateRace, deleteRace } from "./races";

View File

@@ -77,8 +77,8 @@ function buildRacesQuery(query?: RacesQuery): string {
return serialized ? `?${serialized}` : ""; return serialized ? `?${serialized}` : "";
} }
export async function getRaces(query?: RacesQuery): Promise<Race[]> { export async function getRaces(query?: RacesQuery, init?: RequestInit): Promise<Race[]> {
const response = await requestJson<unknown[]>(`/races${buildRacesQuery(query)}`); const response = await requestJson<unknown[]>(`/races${buildRacesQuery(query)}`, init);
if (!Array.isArray(response)) { if (!Array.isArray(response)) {
throw new ApiError({ throw new ApiError({
code: "unknown_error", code: "unknown_error",
@@ -90,8 +90,8 @@ export async function getRaces(query?: RacesQuery): Promise<Race[]> {
return response.map(normalizeRace); return response.map(normalizeRace);
} }
export async function getRaceById(id: string): Promise<Race> { export async function getRaceById(id: string, init?: RequestInit): Promise<Race> {
return normalizeRace(await requestJson<unknown>(`/races/${id}`)); return normalizeRace(await requestJson<unknown>(`/races/${id}`, init));
} }
export async function createRace(payload: CreateRacePayload): Promise<Race> { export async function createRace(payload: CreateRacePayload): Promise<Race> {

View File

@@ -1,11 +1,12 @@
import { NavLink, Outlet } from "react-router-dom"; import { NavLink, Outlet } from "react-router-dom";
import { AppShellFooter } from "./AppShellFooter";
export function AppLayout(): JSX.Element { export function AppLayout(): JSX.Element {
return ( return (
<div className="app-shell"> <div className="app-shell">
<header className="app-shell__header"> <header className="app-shell__header">
<div className="app-shell__brand">Calendar Run</div> <div className="app-shell__brand">Календарь стартов</div>
<nav className="app-shell__nav" aria-label="Primary navigation"> <nav className="app-shell__nav" aria-label="Основная навигация">
<NavLink <NavLink
to="/" to="/"
end end
@@ -13,7 +14,7 @@ export function AppLayout(): JSX.Element {
isActive ? "app-shell__link app-shell__link--active" : "app-shell__link" isActive ? "app-shell__link app-shell__link--active" : "app-shell__link"
} }
> >
Dashboard Обзор
</NavLink> </NavLink>
<NavLink <NavLink
to="/races" to="/races"
@@ -21,7 +22,7 @@ export function AppLayout(): JSX.Element {
isActive ? "app-shell__link app-shell__link--active" : "app-shell__link" isActive ? "app-shell__link app-shell__link--active" : "app-shell__link"
} }
> >
Races Старты
</NavLink> </NavLink>
<NavLink <NavLink
to="/races/new" to="/races/new"
@@ -36,6 +37,7 @@ export function AppLayout(): JSX.Element {
<main className="app-shell__main"> <main className="app-shell__main">
<Outlet /> <Outlet />
</main> </main>
<AppShellFooter />
</div> </div>
); );
} }

View File

@@ -0,0 +1,38 @@
import { useEffect, useState } from "react";
import { getHealth } from "../../api";
export function AppShellFooter(): JSX.Element {
const [backendVersion, setBackendVersion] = useState<string | null>(null);
useEffect(() => {
const ac = new AbortController();
void getHealth({ signal: ac.signal })
.then((h) => {
if (ac.signal.aborted) {
return;
}
setBackendVersion(h.version);
})
.catch(() => {
if (ac.signal.aborted) {
return;
}
setBackendVersion("недоступна");
});
return () => ac.abort();
}, []);
const backendLabel = backendVersion === null ? "…" : backendVersion;
return (
<footer className="app-shell__footer">
<p className="app-shell__footer-versions">
Версия клиента: {__FRONTEND_VERSION__}
<span className="app-shell__footer-sep" aria-hidden="true">
{" · "}
</span>
Версия сервера: {backendLabel}
</p>
</footer>
);
}

View File

@@ -1,15 +1,12 @@
import type { Race } from "../api"; import type { Race } from "../api";
import { formatRaceDate, isCloseDistance, parseFinishTimeToSeconds } from "../lib"; import { formatRaceDate, isCloseDistance, parseFinishTimeToSeconds, parseRaceDate } from "../lib";
type PaceTrendChartProps = { type PaceTrendChartProps = {
races: Race[]; races: Race[];
distanceKm: number; distanceKm: number;
}; };
/** /** Линейный график: время финиша (минуты) по завершённым стартам выбранной дистанции. */
* Minimal SVG sparkline: finish time (minutes) over chronological completed races
* at the selected distance. Lower time = higher point (better).
*/
export function PaceTrendChart(props: PaceTrendChartProps): JSX.Element { export function PaceTrendChart(props: PaceTrendChartProps): JSX.Element {
const { races, distanceKm } = props; const { races, distanceKm } = props;
@@ -21,7 +18,7 @@ export function PaceTrendChart(props: PaceTrendChartProps): JSX.Element {
parseFinishTimeToSeconds(race.finishTime) != null, parseFinishTimeToSeconds(race.finishTime) != null,
) )
.sort( .sort(
(a, b) => new Date(`${a.date}T00:00:00`).getTime() - new Date(`${b.date}T00:00:00`).getTime(), (a, b) => parseRaceDate(a.date).getTime() - parseRaceDate(b.date).getTime(),
) )
.map((race) => { .map((race) => {
const seconds = parseFinishTimeToSeconds(race.finishTime)!; const seconds = parseFinishTimeToSeconds(race.finishTime)!;

View File

@@ -7,6 +7,7 @@ export {
getRaceStatusLabel, getRaceStatusLabel,
isCloseDistance, isCloseDistance,
parseFinishTimeToSeconds, parseFinishTimeToSeconds,
parseRaceDate,
sortByDateAsc, sortByDateAsc,
sortByDateDesc, sortByDateDesc,
splitRacesByDate, splitRacesByDate,

View File

@@ -2,8 +2,14 @@ import type { Race } from "../api";
const MS_IN_DAY = 24 * 60 * 60 * 1000; const MS_IN_DAY = 24 * 60 * 60 * 1000;
function parseRaceDate(date: string): Date { /** API date: YYYY-MM-DD или ISO-строка от сериализации (не склеивать с «T00:00:00» повторно). */
return new Date(`${date}T00:00:00`); export function parseRaceDate(date: string): Date {
const ymd = date.slice(0, 10);
if (/^\d{4}-\d{2}-\d{2}$/.test(ymd)) {
return new Date(`${ymd}T00:00:00`);
}
const parsed = new Date(date);
return parsed;
} }
export function parseFinishTimeToSeconds(value: string | null): number | null { export function parseFinishTimeToSeconds(value: string | null): number | null {

View File

@@ -9,6 +9,7 @@ import {
getPaceLabel, getPaceLabel,
isCloseDistance, isCloseDistance,
parseFinishTimeToSeconds, parseFinishTimeToSeconds,
parseRaceDate,
splitRacesByDate, splitRacesByDate,
} from "../lib"; } from "../lib";
@@ -18,7 +19,7 @@ function getErrorMessage(error: unknown): string {
if (error instanceof ApiError) { if (error instanceof ApiError) {
return error.message; return error.message;
} }
return "Не удалось загрузить данные dashboard."; return "Не удалось загрузить данные обзора.";
} }
export function DashboardPage(): JSX.Element { export function DashboardPage(): JSX.Element {
@@ -28,23 +29,24 @@ export function DashboardPage(): JSX.Element {
const [chartDistanceKm, setChartDistanceKm] = useState<number>(10); const [chartDistanceKm, setChartDistanceKm] = useState<number>(10);
useEffect(() => { useEffect(() => {
const ac = new AbortController();
let isMounted = true; let isMounted = true;
async function loadDashboardData(): Promise<void> { async function loadDashboardData(): Promise<void> {
try { try {
const items = await getRaces(); const items = await getRaces(undefined, { signal: ac.signal });
if (!isMounted) { if (!isMounted || ac.signal.aborted) {
return; return;
} }
setRaces(items); setRaces(items);
setErrorMessage(null); setErrorMessage(null);
} catch (error) { } catch (error) {
if (!isMounted) { if (ac.signal.aborted || !isMounted) {
return; return;
} }
setErrorMessage(getErrorMessage(error)); setErrorMessage(getErrorMessage(error));
} finally { } finally {
if (isMounted) { if (isMounted && !ac.signal.aborted) {
setIsLoading(false); setIsLoading(false);
} }
} }
@@ -53,6 +55,7 @@ export function DashboardPage(): JSX.Element {
void loadDashboardData(); void loadDashboardData();
return () => { return () => {
isMounted = false; isMounted = false;
ac.abort();
}; };
}, []); }, []);
@@ -80,7 +83,7 @@ export function DashboardPage(): JSX.Element {
} }
const currentYear = new Date().getFullYear(); const currentYear = new Date().getFullYear();
const seasonRaces = races.filter((race) => new Date(`${race.date}T00:00:00`).getFullYear() === currentYear); const seasonRaces = races.filter((race) => parseRaceDate(race.date).getFullYear() === currentYear);
const seasonCompleted = seasonRaces.filter((race) => race.status === "completed"); const seasonCompleted = seasonRaces.filter((race) => race.status === "completed");
return { return {
@@ -130,7 +133,7 @@ export function DashboardPage(): JSX.Element {
.filter((race) => race.status === "completed") .filter((race) => race.status === "completed")
.map((race) => ({ .map((race) => ({
id: race.id, id: race.id,
year: new Date(`${race.date}T00:00:00`).getFullYear(), year: parseRaceDate(race.date).getFullYear(),
title: race.title, title: race.title,
distance: formatDistance(race.distanceKm), distance: formatDistance(race.distanceKm),
finishTime: race.finishTime ?? "время не указано", finishTime: race.finishTime ?? "время не указано",
@@ -143,7 +146,7 @@ export function DashboardPage(): JSX.Element {
if (isLoading) { if (isLoading) {
return ( return (
<section className="page page--dashboard" aria-busy="true"> <section className="page page--dashboard" aria-busy="true">
<h1 className="page__title">Dashboard</h1> <h1 className="page__title">Обзор</h1>
<p className="page__subtitle">Загружаем ваши старты...</p> <p className="page__subtitle">Загружаем ваши старты...</p>
</section> </section>
); );
@@ -152,7 +155,7 @@ export function DashboardPage(): JSX.Element {
if (errorMessage) { if (errorMessage) {
return ( return (
<section className="page page--dashboard" role="alert"> <section className="page page--dashboard" role="alert">
<h1 className="page__title">Dashboard</h1> <h1 className="page__title">Обзор</h1>
<p className="page__subtitle page__subtitle--error">{errorMessage}</p> <p className="page__subtitle page__subtitle--error">{errorMessage}</p>
</section> </section>
); );
@@ -160,7 +163,7 @@ export function DashboardPage(): JSX.Element {
return ( return (
<section className="page page--dashboard"> <section className="page page--dashboard">
<h1 className="page__title">Dashboard</h1> <h1 className="page__title">Обзор</h1>
<p className="page__subtitle">Ключевые метрики по вашему календарю стартов.</p> <p className="page__subtitle">Ключевые метрики по вашему календарю стартов.</p>
<div className="dashboard-grid" aria-label="Ключевые метрики"> <div className="dashboard-grid" aria-label="Ключевые метрики">
@@ -205,7 +208,7 @@ export function DashboardPage(): JSX.Element {
<p className="dashboard-card__hint">Лучший темп среди завершённых стартов.</p> <p className="dashboard-card__hint">Лучший темп среди завершённых стартов.</p>
</> </>
) : ( ) : (
<p className="dashboard-card__empty">Недостаточно данных для PR.</p> <p className="dashboard-card__empty">Недостаточно данных для личного рекорда.</p>
)} )}
</article> </article>
@@ -242,7 +245,7 @@ export function DashboardPage(): JSX.Element {
</section> </section>
<section className="dashboard-section" aria-label="Личные рекорды по дистанциям"> <section className="dashboard-section" aria-label="Личные рекорды по дистанциям">
<h2 className="dashboard-section__title">PR по дистанциям</h2> <h2 className="dashboard-section__title">Рекорды по дистанциям</h2>
<div className="dashboard-grid dashboard-grid--pr"> <div className="dashboard-grid dashboard-grid--pr">
{personalRecordsByDistance.map((item) => ( {personalRecordsByDistance.map((item) => (
<article key={item.distanceKm} className="dashboard-card"> <article key={item.distanceKm} className="dashboard-card">
@@ -291,7 +294,7 @@ export function DashboardPage(): JSX.Element {
</table> </table>
</div> </div>
) : ( ) : (
<p className="dashboard-card__empty">Нет completed-стартов для сравнения.</p> <p className="dashboard-card__empty">Нет завершённых стартов для сравнения.</p>
)} )}
</section> </section>
</section> </section>

View File

@@ -57,6 +57,7 @@ export function RaceDetailsPage(): JSX.Element {
const [showDeleteConfirm, setShowDeleteConfirm] = useState<boolean>(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState<boolean>(false);
useEffect(() => { useEffect(() => {
const ac = new AbortController();
let isMounted = true; let isMounted = true;
async function loadRace(): Promise<void> { async function loadRace(): Promise<void> {
@@ -67,19 +68,19 @@ export function RaceDetailsPage(): JSX.Element {
} }
try { try {
const item = await getRaceById(raceId); const item = await getRaceById(raceId, { signal: ac.signal });
if (!isMounted) { if (!isMounted || ac.signal.aborted) {
return; return;
} }
setRace(item); setRace(item);
setErrorMessage(null); setErrorMessage(null);
} catch (error) { } catch (error) {
if (!isMounted) { if (ac.signal.aborted || !isMounted) {
return; return;
} }
setErrorMessage(getErrorMessage(error)); setErrorMessage(getErrorMessage(error));
} finally { } finally {
if (isMounted) { if (isMounted && !ac.signal.aborted) {
setIsLoading(false); setIsLoading(false);
} }
} }
@@ -88,6 +89,7 @@ export function RaceDetailsPage(): JSX.Element {
void loadRace(); void loadRace();
return () => { return () => {
isMounted = false; isMounted = false;
ac.abort();
}; };
}, [raceId]); }, [raceId]);

View File

@@ -54,8 +54,9 @@ const EMPTY_FORM: FormData = {
}; };
function raceToFormData(race: Race): FormData { function raceToFormData(race: Race): FormData {
const dateValue = race.date.length >= 10 ? race.date.slice(0, 10) : race.date;
return { return {
date: race.date, date: dateValue,
title: race.title, title: race.title,
distanceKm: String(race.distanceKm), distanceKm: String(race.distanceKm),
status: race.status ?? "", status: race.status ?? "",
@@ -310,7 +311,7 @@ export function RaceFormPage(): JSX.Element {
name="officialUrl" name="officialUrl"
value={form.officialUrl} value={form.officialUrl}
onChange={handleChange} onChange={handleChange}
placeholder="https://example.com" placeholder="https://"
/> />
</label> </label>

View File

@@ -100,24 +100,25 @@ export function RacesPage(): JSX.Element {
}, [yearFilter, monthFilter]); }, [yearFilter, monthFilter]);
useEffect(() => { useEffect(() => {
const ac = new AbortController();
let isMounted = true; let isMounted = true;
async function loadRaces(): Promise<void> { async function loadRaces(): Promise<void> {
setIsLoading(true); setIsLoading(true);
try { try {
const items = await getRaces(listQuery); const items = await getRaces(listQuery, { signal: ac.signal });
if (!isMounted) { if (!isMounted || ac.signal.aborted) {
return; return;
} }
setRaces(items); setRaces(items);
setErrorMessage(null); setErrorMessage(null);
} catch (error) { } catch (error) {
if (!isMounted) { if (ac.signal.aborted || !isMounted) {
return; return;
} }
setErrorMessage(getErrorMessage(error)); setErrorMessage(getErrorMessage(error));
} finally { } finally {
if (isMounted) { if (isMounted && !ac.signal.aborted) {
setIsLoading(false); setIsLoading(false);
} }
} }
@@ -126,6 +127,7 @@ export function RacesPage(): JSX.Element {
void loadRaces(); void loadRaces();
return () => { return () => {
isMounted = false; isMounted = false;
ac.abort();
}; };
}, [listQuery]); }, [listQuery]);

View File

@@ -27,7 +27,7 @@ a {
.app-shell { .app-shell {
min-height: 100vh; min-height: 100vh;
display: grid; display: grid;
grid-template-rows: auto 1fr; grid-template-rows: auto 1fr auto;
} }
.app-shell__header { .app-shell__header {
@@ -77,6 +77,24 @@ a {
padding: var(--space-6); padding: var(--space-6);
} }
.app-shell__footer {
margin-top: auto;
padding: var(--space-3) var(--space-6);
border-top: 1px solid var(--color-border);
background: var(--color-surface);
}
.app-shell__footer-versions {
margin: 0;
text-align: center;
font-size: var(--font-size-caption);
color: var(--color-text-muted);
}
.app-shell__footer-sep {
user-select: none;
}
.page { .page {
background: var(--color-surface); background: var(--color-surface);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);

3
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,3 @@
/// <reference types="vite/client" />
declare const __FRONTEND_VERSION__: string;

View File

@@ -1,6 +1,15 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const pkg = JSON.parse(readFileSync(path.join(__dirname, "package.json"), "utf-8")) as { version: string };
export default defineConfig({ export default defineConfig({
plugins: [react()] plugins: [react()],
define: {
__FRONTEND_VERSION__: JSON.stringify(pkg.version),
},
}); });