Compare commits
2 Commits
chore/clea
...
fix/phase1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b8f41f905 | ||
| b422223c03 |
102
FRONTEND_PLAN.md
Normal file
102
FRONTEND_PLAN.md
Normal file
@@ -0,0 +1,102 @@
|
||||
---
|
||||
|
||||
name: Frontend implementation plan
|
||||
overview: Собрать минималистичный frontend для календаря забегов по UI-инструкции, строго в рамках текущего API-контракта, с отдельной внешней задачей на недостающие backend-поля для completed-забегов.
|
||||
todos:
|
||||
|
||||
- id: frontend-structure
|
||||
content: Подготовить структуру frontend, роутинг, базовые layout-компоненты и дизайн-токены на CSS variables + BEM
|
||||
status: completed
|
||||
- id: api-contract-layer
|
||||
content: Реализовать типизированный API-клиент и слой нормализации/обработки ошибок по контракту backend-api-for-frontend.md
|
||||
status: completed
|
||||
- id: dashboard-and-calendar
|
||||
content: Собрать Dashboard, списки будущих/прошедших стартов и базовые карточки по минималистичному UI-гайду
|
||||
status: completed
|
||||
- id: race-details-and-metrics
|
||||
content: Реализовать экран карточки старта, вычисление темпа на фронте и отображение completed-метрик
|
||||
status: completed
|
||||
- id: pr-and-comparison
|
||||
content: Сделать блок PR и сравнение стартов с fallback для отсутствующего поля place
|
||||
status: completed
|
||||
- id: backend-dependency-task
|
||||
content: Подготовить отдельную задачу для другого агента на расширение API полем place и последующую интеграцию во frontend
|
||||
status: pending
|
||||
isProject: false
|
||||
|
||||
---
|
||||
|
||||
# План frontend части Calendar Run
|
||||
|
||||
## Исходные опоры
|
||||
|
||||
- UI и UX принципы берём строго из [d:\vaka.pro\calendar_run\agent-frontend-ui-instructions.md](d:/vaka.pro/calendar_run/agent-frontend-ui-instructions.md): минимализм, воздух, акцент на данных, спокойная палитра, быстрые сценарии.
|
||||
- Продуктовые ограничения и структура экранов сверяем с [d:\vaka.pro\calendar_run\PLAN.md](d:/vaka.pro/calendar_run/PLAN.md).
|
||||
- Интеграционный контракт берём из [d:\vaka.pro\calendar_run\docs\backend-api-for-frontend.md](d:/vaka.pro/calendar_run/docs/backend-api-for-frontend.md).
|
||||
- Общий контекст запуска/окружения — [d:\vaka.pro\calendar_run\README.md](d:/vaka.pro/calendar_run/README.md) и [d:\vaka.pro\calendar_run\docs\backend.md](d:/vaka.pro/calendar_run/docs/backend.md).
|
||||
|
||||
## Границы версии (V1)
|
||||
|
||||
- Только frontend + интеграция с текущим API.
|
||||
- Статус `зарегистрирован` трактуется как UI-вариант `planned` (без изменения backend-контракта).
|
||||
- Для completed-забегов обязательно показываем `темп`; считаем на фронте из `finishTime` и `distanceKm`.
|
||||
- Для completed-забегов поле `место` обязательно по UX, но в API отсутствует: выделяем отдельную задачу другому агенту на расширение backend (`place`) и считаем это внешней зависимостью.
|
||||
|
||||
## Архитектура frontend
|
||||
|
||||
- Базовая структура: `frontend/src/app`, `frontend/src/pages`, `frontend/src/components`, `frontend/src/api`, `frontend/src/features`, `frontend/src/lib`, `frontend/src/styles`.
|
||||
- Дизайн-система на CSS variables: токены цвета/типографики/отступов/радиусов, единые состояния (`success`, `warning`, `error`).
|
||||
- БЭМ для всех UI-блоков и модификаторов (`block`, `block__element`, `block--modifier`).
|
||||
- Единый слой API-клиента:
|
||||
- `GET /races`, `GET /races/:id`, `POST /races`, `PATCH /races/:id`, `DELETE /races/:id` (если используется UI-сценарием).
|
||||
- Типы `Race`, `RaceStatus`, DTO для POST/PATCH.
|
||||
- Централизованный маппинг ошибок API (`validation_error`, `not_found`, `database_unavailable`, `conflict`) в UX-сообщения.
|
||||
|
||||
## Экранная модель и сценарии
|
||||
|
||||
- Dashboard:
|
||||
- `Ближайший старт`, `Последний результат`, `Личный рекорд`, `Сезон`.
|
||||
- CTA к календарю и добавлению старта.
|
||||
- Календарь стартов:
|
||||
- Переключение `Будущие` / `Прошедшие`.
|
||||
- Карточка старта: `title`, `date`, `distanceKm`, статус-лейбл.
|
||||
- Карточка старта:
|
||||
- Базовые поля + `finishTime`, вычисляемый `pace`, `notes`.
|
||||
- `place` — вывод включается после backend-расширения.
|
||||
- PR блок:
|
||||
- Дистанции: 5K, 10K, 21.1, 42.2 (согласно UI-инструкции).
|
||||
- Расчёт по completed-забегам с валидным `finishTime`.
|
||||
- Сравнение стартов (ключевая фича):
|
||||
- Таблица/карточки по годам с `time`, `pace`, `place`.
|
||||
- До появления `place` в API — graceful-degradation: колонка в состоянии «нет данных».
|
||||
|
||||
## UX и визуальные требования
|
||||
|
||||
- Визуальная система: светлый фон, белые карточки, один акцентный цвет, без кислотных сочетаний.
|
||||
- Иерархия типографики: H1/H2/body/caption, крупные числовые метрики.
|
||||
- Минимум визуального шума, 2–3 клика на частые действия.
|
||||
- Консистентные состояния загрузки/ошибок/пустых данных.
|
||||
- A11y-базис: фокус-стили, клавиатурная навигация, контраст, корректная разметка интерактивных элементов.
|
||||
|
||||
## Отдельная зависимая задача (другой агент)
|
||||
|
||||
- Создать отдельную backend-задачу: добавить поле `place` в модель `Race` (миграция + API + документация контракта).
|
||||
- После доставки backend-изменения: обновить frontend-типы, формы и блок сравнения, заменить placeholder на реальное значение.
|
||||
|
||||
## Порядок реализации
|
||||
|
||||
1. Подготовить каркас frontend и дизайн-токены (BEM + CSS variables).
|
||||
2. Реализовать API-клиент и типы данных с обработкой ошибок.
|
||||
3. Собрать Dashboard и календарные списки (будущие/прошедшие).
|
||||
4. Реализовать карточку старта с вычислением `pace` на клиенте.
|
||||
5. Реализовать PR и блок сравнения стартов с fallback для `place`.
|
||||
6. Добавить состояния пустых данных/ошибок/загрузки и а11y-полировку.
|
||||
7. Подготовить handoff-note с зависимостью на backend-задачу (`place`) и интеграционным чеклистом.
|
||||
|
||||
## Definition of Done для frontend
|
||||
|
||||
- Все ключевые экраны из UI-инструкции доступны и консистентны визуально.
|
||||
- API-интеграция работает по текущему контракту без локальных обходов хранилища.
|
||||
- `pace` считается корректно для completed-забегов.
|
||||
- `registered` не ломает модель: визуально интерпретируется в рамках `planned`.
|
||||
- Для `place` есть явная внешняя задача и безопасный fallback в UI.
|
||||
100
backend/package-lock.json
generated
100
backend/package-lock.json
generated
@@ -20,6 +20,7 @@
|
||||
"@types/node": "^22.12.0",
|
||||
"@types/pg": "^8.11.10",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"cross-env": "^10.1.0",
|
||||
"supertest": "^7.0.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsx": "^4.19.2",
|
||||
@@ -39,6 +40,13 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@epic-web/invariant": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz",
|
||||
"integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
|
||||
@@ -643,6 +651,7 @@
|
||||
"integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
@@ -936,6 +945,39 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cross-env": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz",
|
||||
"integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@epic-web/invariant": "^1.0.0",
|
||||
"cross-spawn": "^7.0.6"
|
||||
},
|
||||
"bin": {
|
||||
"cross-env": "dist/bin/cross-env.js",
|
||||
"cross-env-shell": "dist/bin/cross-env-shell.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"path-key": "^3.1.0",
|
||||
"shebang-command": "^2.0.0",
|
||||
"which": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/csv-parse": {
|
||||
"version": "5.6.0",
|
||||
"resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-5.6.0.tgz",
|
||||
@@ -1442,6 +1484,13 @@
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/isexe": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/make-error": {
|
||||
"version": "1.3.6",
|
||||
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
|
||||
@@ -1585,6 +1634,16 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-key": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-to-regexp": {
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
|
||||
@@ -1596,6 +1655,7 @@
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
|
||||
"integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.12.0",
|
||||
"pg-pool": "^3.13.0",
|
||||
@@ -1858,6 +1918,29 @@
|
||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"shebang-regex": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/shebang-regex": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
||||
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
||||
@@ -2124,6 +2207,7 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -2173,6 +2257,22 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"isexe": "^2.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"node-which": "bin/node-which"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/wrappy": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"start": "node dist/index.js",
|
||||
"db:migrate": "ts-node src/migrate.ts",
|
||||
"seed": "ts-node src/seed.ts",
|
||||
"test": "CALENDAR_RUN_MOCK_DB=1 tsx --test test/app.test.ts"
|
||||
"test": "cross-env CALENDAR_RUN_MOCK_DB=1 tsx --test test/app.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
@@ -23,6 +23,7 @@
|
||||
"@types/node": "^22.12.0",
|
||||
"@types/pg": "^8.11.10",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"cross-env": "^10.1.0",
|
||||
"supertest": "^7.0.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsx": "^4.19.2",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import express from "express";
|
||||
import express, { Request, Response, NextFunction } from "express";
|
||||
import cors from "cors";
|
||||
import { config } from "./config";
|
||||
import healthRouter from "./routes/health";
|
||||
@@ -13,5 +13,14 @@ export function createApp(): express.Express {
|
||||
app.use(healthRouter);
|
||||
app.use(racesRouter);
|
||||
|
||||
app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => {
|
||||
if (err instanceof SyntaxError && "body" in err) {
|
||||
res.status(400).json({ error: "validation_error", details: ["Invalid JSON in request body"] });
|
||||
return;
|
||||
}
|
||||
console.error("[app] Unhandled error:", err);
|
||||
res.status(500).json({ error: "unknown_error", details: ["Internal server error"] });
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -69,6 +69,8 @@ function createMockPool(): Pool {
|
||||
fields: [],
|
||||
}) as QueryResult<T>;
|
||||
|
||||
const store = new Map<string, RaceRow>();
|
||||
|
||||
const mockQuery = async <T extends QueryResultRow>(
|
||||
text: string,
|
||||
params?: unknown[],
|
||||
@@ -76,11 +78,9 @@ function createMockPool(): Pool {
|
||||
const sql = text.replace(/\s+/g, " ").trim();
|
||||
const p = params ?? [];
|
||||
|
||||
if (sql.includes("DELETE FROM races")) {
|
||||
return emptyResult();
|
||||
}
|
||||
if (sql.includes("INSERT INTO races") && sql.includes("RETURNING")) {
|
||||
const row = mockRowFromInsert(text, p);
|
||||
store.set(row.id, row);
|
||||
return {
|
||||
rows: [row as unknown as T],
|
||||
rowCount: 1,
|
||||
@@ -89,12 +89,49 @@ function createMockPool(): Pool {
|
||||
fields: [],
|
||||
} as QueryResult<T>;
|
||||
}
|
||||
|
||||
if (sql.includes("DELETE FROM races")) {
|
||||
const id = String(p[0] ?? "");
|
||||
const existed = store.delete(id);
|
||||
return {
|
||||
rows: [],
|
||||
rowCount: existed ? 1 : 0,
|
||||
command: "DELETE",
|
||||
oid: 0,
|
||||
fields: [],
|
||||
} as QueryResult<T>;
|
||||
}
|
||||
|
||||
if (sql.includes("UPDATE races") && sql.includes("RETURNING")) {
|
||||
const id = String(p[p.length - 1] ?? "");
|
||||
const existing = store.get(id);
|
||||
if (!existing) {
|
||||
return emptyResult();
|
||||
}
|
||||
const updated = { ...existing, updated_at: new Date().toISOString() };
|
||||
store.set(id, updated);
|
||||
return {
|
||||
rows: [updated as unknown as T],
|
||||
rowCount: 1,
|
||||
command: "UPDATE",
|
||||
oid: 0,
|
||||
fields: [],
|
||||
} as QueryResult<T>;
|
||||
}
|
||||
|
||||
if (sql.includes("SELECT * FROM races WHERE id =")) {
|
||||
const id = String(p[0] ?? "");
|
||||
const row = store.get(id);
|
||||
return row
|
||||
? { rows: [row as unknown as T], rowCount: 1, command: "SELECT", oid: 0, fields: [] } as QueryResult<T>
|
||||
: emptyResult();
|
||||
}
|
||||
|
||||
if (sql.includes("SELECT * FROM races")) {
|
||||
return emptyResult();
|
||||
const rows = Array.from(store.values());
|
||||
return { rows: rows as unknown as T[], rowCount: rows.length, command: "SELECT", oid: 0, fields: [] } as QueryResult<T>;
|
||||
}
|
||||
|
||||
return emptyResult();
|
||||
};
|
||||
|
||||
|
||||
@@ -70,6 +70,22 @@ export function splitRacesByDate(races: Race[], now: Date = new Date()): { upcom
|
||||
};
|
||||
}
|
||||
|
||||
function pluralizeDays(n: number): string {
|
||||
const mod10 = n % 10;
|
||||
const mod100 = n % 100;
|
||||
|
||||
if (mod100 >= 11 && mod100 <= 19) {
|
||||
return "дней";
|
||||
}
|
||||
if (mod10 === 1) {
|
||||
return "день";
|
||||
}
|
||||
if (mod10 >= 2 && mod10 <= 4) {
|
||||
return "дня";
|
||||
}
|
||||
return "дней";
|
||||
}
|
||||
|
||||
export function getRaceCountdownLabel(date: string, now: Date = new Date()): string {
|
||||
const today = new Date(now);
|
||||
today.setHours(0, 0, 0, 0);
|
||||
@@ -80,13 +96,7 @@ export function getRaceCountdownLabel(date: string, now: Date = new Date()): str
|
||||
if (days <= 0) {
|
||||
return "сегодня";
|
||||
}
|
||||
if (days === 1) {
|
||||
return "через 1 день";
|
||||
}
|
||||
if (days < 5) {
|
||||
return `через ${days} дня`;
|
||||
}
|
||||
return `через ${days} дней`;
|
||||
return `через ${days} ${pluralizeDays(days)}`;
|
||||
}
|
||||
|
||||
export function isCloseDistance(left: number, right: number): boolean {
|
||||
|
||||
@@ -145,6 +145,12 @@ export function RacesPage(): JSX.Element {
|
||||
<h1 className="page__title">Календарь стартов</h1>
|
||||
<p className="page__subtitle">Будущие и прошедшие старты в одном месте.</p>
|
||||
|
||||
{errorMessage && !isLoading ? (
|
||||
<p className="page__subtitle page__subtitle--error" role="alert" style={{ marginTop: "var(--space-4)" }}>
|
||||
{errorMessage}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="races-filter" role="search" aria-label="Фильтр по дате">
|
||||
<label className="races-filter__field">
|
||||
<span className="races-filter__label">Год</span>
|
||||
|
||||
@@ -64,7 +64,9 @@ a {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.app-shell__link--active {
|
||||
.app-shell__link--active,
|
||||
.app-shell__link--active:hover,
|
||||
.app-shell__link--active:focus-visible {
|
||||
color: var(--color-surface);
|
||||
background: var(--color-accent);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
--font-size-caption: 0.875rem;
|
||||
--line-height-base: 1.5;
|
||||
|
||||
--space-1: 0.25rem;
|
||||
--space-2: 0.5rem;
|
||||
--space-3: 0.75rem;
|
||||
--space-4: 1rem;
|
||||
|
||||
Reference in New Issue
Block a user