feat(proj): init
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Tile Server</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,36 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
gzip on;
|
||||
gzip_types text/css application/javascript application/json;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://tile-server:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
proxy_redirect off;
|
||||
}
|
||||
|
||||
location /u/ {
|
||||
proxy_pass http://tile-server:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
gzip off;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
Generated
+2239
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "tile-server-web",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"maplibre-gl": "^5.6.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.30.1",
|
||||
"styled-components": "^6.1.19"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.0.7"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { type ReactNode } from "react";
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import { AppShell } from "./components/layout/AppShell";
|
||||
import { useAuth } from "./features/auth/AuthContext";
|
||||
import { LoginPage } from "./features/auth/LoginPage";
|
||||
import { DashboardPage } from "./features/usage/DashboardPage";
|
||||
import { StylesPage } from "./features/styles/StylesPage";
|
||||
import { StyleEditorPage } from "./features/styles/StyleEditorPage";
|
||||
import { TokensPage } from "./features/tokens/TokensPage";
|
||||
import { GuidesPage } from "./features/guides/GuidesPage";
|
||||
import { CookieBanner } from "./legal/CookieBanner";
|
||||
import { LegalPage } from "./legal/LegalPage";
|
||||
|
||||
function Guard({ children }: { children: ReactNode }) {
|
||||
const { user, loading, logout } = useAuth();
|
||||
if (loading) {
|
||||
return <p className="muted">Загрузка…</p>;
|
||||
}
|
||||
if (!user) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
return (
|
||||
<AppShell userLabel={user.displayName || user.login} onLogout={() => void logout()}>
|
||||
{children}
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const { user, loading } = useAuth();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Routes>
|
||||
<Route path="/login" element={user && !loading ? <Navigate to="/" replace /> : <LoginPage />} />
|
||||
<Route path="/legal/:slug" element={<LegalPage />} />
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<Guard>
|
||||
<DashboardPage />
|
||||
</Guard>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/guides"
|
||||
element={
|
||||
<Guard>
|
||||
<GuidesPage />
|
||||
</Guard>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/styles"
|
||||
element={
|
||||
<Guard>
|
||||
<StylesPage />
|
||||
</Guard>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/styles/:name"
|
||||
element={
|
||||
<Guard>
|
||||
<StyleEditorPage />
|
||||
</Guard>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/tokens"
|
||||
element={
|
||||
<Guard>
|
||||
<TokensPage />
|
||||
</Guard>
|
||||
}
|
||||
/>
|
||||
<Route path="*" element={<Navigate to={user ? "/" : "/login"} replace />} />
|
||||
</Routes>
|
||||
<CookieBanner />
|
||||
</>
|
||||
);
|
||||
}
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public readonly status: number,
|
||||
message: string
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const headers = new Headers(init.headers);
|
||||
if (init.body && !headers.has("Content-Type")) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
|
||||
const response = await fetch(path, {
|
||||
...init,
|
||||
headers,
|
||||
credentials: "include"
|
||||
});
|
||||
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
const payload = text ? (JSON.parse(text) as unknown) : undefined;
|
||||
if (!response.ok) {
|
||||
const detail =
|
||||
payload && typeof payload === "object" && "detail" in payload
|
||||
? String((payload as { detail?: string }).detail)
|
||||
: response.statusText;
|
||||
throw new ApiError(response.status, detail || "Request failed");
|
||||
}
|
||||
|
||||
return payload as T;
|
||||
}
|
||||
|
||||
export type Me = {
|
||||
id: string;
|
||||
login: string;
|
||||
displayName: string;
|
||||
slug: string;
|
||||
yandexId: string;
|
||||
};
|
||||
|
||||
export type AuthResponse = {
|
||||
user: Me;
|
||||
cabinetToken?: string | null;
|
||||
};
|
||||
|
||||
export type YandexProvider = {
|
||||
enabled: boolean;
|
||||
clientId?: string | null;
|
||||
redirectUri?: string | null;
|
||||
};
|
||||
|
||||
export type AuthProviders = {
|
||||
yandex: YandexProvider;
|
||||
};
|
||||
|
||||
export type UsageDay = {
|
||||
date: string;
|
||||
tileRequests: number;
|
||||
styleRequests: number;
|
||||
bytes: number;
|
||||
};
|
||||
|
||||
export type TokenItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
prefix: string;
|
||||
createdAt: string;
|
||||
revokedAt?: string | null;
|
||||
};
|
||||
|
||||
export type CreatedToken = TokenItem & { token: string };
|
||||
|
||||
export type StyleItem = {
|
||||
name: string;
|
||||
createdFrom?: string | null;
|
||||
updatedAt: string;
|
||||
isPreset: boolean;
|
||||
};
|
||||
|
||||
export type SourceItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
center: number[];
|
||||
};
|
||||
|
||||
export const api = {
|
||||
me: () => request<Me>("/api/v1/me"),
|
||||
providers: () => request<AuthProviders>("/api/v1/auth/providers"),
|
||||
loginYandex: (code: string, redirectUri: string, state?: string | null) =>
|
||||
request<AuthResponse>("/api/v1/auth/yandex", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ code, state, redirectUri })
|
||||
}),
|
||||
logout: () => request<void>("/api/v1/auth/logout", { method: "POST" }),
|
||||
usage: (from: string, to: string) =>
|
||||
request<UsageDay[]>(`/api/v1/me/usage?from=${from}&to=${to}`),
|
||||
tokens: () => request<TokenItem[]>("/api/v1/me/tokens"),
|
||||
createToken: (name: string) =>
|
||||
request<CreatedToken>("/api/v1/me/tokens", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name })
|
||||
}),
|
||||
revokeToken: (id: string) =>
|
||||
request<void>(`/api/v1/me/tokens/${id}`, { method: "DELETE" }),
|
||||
styles: () => request<StyleItem[]>("/api/v1/me/styles"),
|
||||
getStyle: (name: string) => request<unknown>(`/api/v1/me/styles/${name}`),
|
||||
cloneStyle: (name: string, from: string) =>
|
||||
request<StyleItem>("/api/v1/me/styles", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name, from })
|
||||
}),
|
||||
saveStyle: (name: string, style: unknown) =>
|
||||
request<void>(`/api/v1/me/styles/${name}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(style)
|
||||
}),
|
||||
deleteStyle: (name: string) =>
|
||||
request<void>(`/api/v1/me/styles/${name}`, { method: "DELETE" }),
|
||||
sources: () => request<SourceItem[]>("/api/v1/sources")
|
||||
};
|
||||
|
||||
const TOKEN_KEY = "ts.previewToken";
|
||||
|
||||
export function getPreviewToken(): string | null {
|
||||
return sessionStorage.getItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function setPreviewToken(token: string): void {
|
||||
sessionStorage.setItem(TOKEN_KEY, token);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
const YANDEX_AUTHORIZE = "https://oauth.yandex.ru/authorize";
|
||||
|
||||
export function yandexCodeFromQuery(search: string): string | null {
|
||||
const params = new URLSearchParams(search.startsWith("?") ? search : `?${search}`);
|
||||
const code = params.get("code")?.trim();
|
||||
if (!code) return null;
|
||||
// Telegram login also lands on /login with id/hash — never treat that as Yandex.
|
||||
if (params.has("hash") && params.has("id")) return null;
|
||||
return code;
|
||||
}
|
||||
|
||||
export function yandexErrorFromQuery(search: string): string | null {
|
||||
const params = new URLSearchParams(search.startsWith("?") ? search : `?${search}`);
|
||||
if (params.has("hash") && params.has("id")) return null;
|
||||
return params.get("error_description")?.trim() || params.get("error")?.trim() || null;
|
||||
}
|
||||
|
||||
export function yandexAuthorizeUrl(
|
||||
clientId: string,
|
||||
redirectUri: string,
|
||||
state?: string | null,
|
||||
): string {
|
||||
const params = new URLSearchParams({
|
||||
response_type: "code",
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
force_confirm: "yes",
|
||||
});
|
||||
if (state) params.set("state", state);
|
||||
return `${YANDEX_AUTHORIZE}?${params.toString()}`;
|
||||
}
|
||||
|
||||
/** Must match the Callback URL registered in the Yandex app (trailing slash). */
|
||||
export function yandexRedirectUri(
|
||||
configured?: string | null,
|
||||
origin = window.location.origin,
|
||||
): string {
|
||||
const local = `${origin.replace(/\/$/, "")}/`;
|
||||
if (!configured) return local;
|
||||
try {
|
||||
if (new URL(configured).origin === new URL(local).origin) return configured;
|
||||
} catch {
|
||||
// Fall through to the current origin.
|
||||
}
|
||||
return local;
|
||||
}
|
||||
|
||||
/** @deprecated use yandexRedirectUri */
|
||||
export function cabinetLoginRedirectUri(origin = window.location.origin): string {
|
||||
return yandexRedirectUri(null, origin);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const APP_NAME = "Tile Server";
|
||||
@@ -0,0 +1,73 @@
|
||||
import { NavLink, useLocation } from "react-router-dom";
|
||||
import type { ReactNode } from "react";
|
||||
import { APP_NAME } from "../../brand";
|
||||
import { SiteFooter } from "../../legal/SiteFooter";
|
||||
import { AppAvatar, AppButton, AppText } from "../../ui";
|
||||
import { PRIMARY_NAV } from "./nav";
|
||||
|
||||
type Props = {
|
||||
brand?: string;
|
||||
userLabel: string;
|
||||
onLogout: () => void;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
function initials(label: string): string {
|
||||
const parts = label.trim().split(/\s+/).filter(Boolean);
|
||||
if (parts.length === 0) {
|
||||
return "•";
|
||||
}
|
||||
if (parts.length === 1) {
|
||||
return parts[0].slice(0, 1).toUpperCase();
|
||||
}
|
||||
return (parts[0][0] + parts[1][0]).toUpperCase();
|
||||
}
|
||||
|
||||
export function AppShell({
|
||||
brand = APP_NAME,
|
||||
userLabel,
|
||||
onLogout,
|
||||
children,
|
||||
}: Props) {
|
||||
const location = useLocation();
|
||||
const workspace = /^\/styles\/[^/]+$/.test(location.pathname);
|
||||
|
||||
return (
|
||||
<div className={`app-shell${workspace ? " app-shell--workspace" : ""}`}>
|
||||
<header className="app-shell__header">
|
||||
<AppText variant="headline" tone="accent">
|
||||
{brand}
|
||||
</AppText>
|
||||
<div className="app-shell__user">
|
||||
<AppAvatar initials={initials(userLabel)} />
|
||||
<span className="app-shell__chip" title={userLabel}>
|
||||
{userLabel}
|
||||
</span>
|
||||
<AppButton buttonStyle="plain" size="small" expanded={false} onClick={onLogout}>
|
||||
Выйти
|
||||
</AppButton>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav className="app-shell__nav" aria-label="Основное меню">
|
||||
<p className="app-section-header">Кабинет</p>
|
||||
<ul className="app-shell__nav-list">
|
||||
{PRIMARY_NAV.map((item) => (
|
||||
<li key={item.id}>
|
||||
<NavLink
|
||||
to={item.to}
|
||||
end={item.to === "/"}
|
||||
className={({ isActive }) => `app-shell__nav-link${isActive ? " is-active" : ""}`}
|
||||
>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<SiteFooter />
|
||||
</nav>
|
||||
|
||||
<div className="app-shell__main">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { APP_NAME } from "../../brand";
|
||||
import { AppText } from "../../ui";
|
||||
|
||||
type Props = {
|
||||
brand?: string;
|
||||
title: string;
|
||||
lead: string;
|
||||
cta: ReactNode;
|
||||
footer?: ReactNode;
|
||||
flash?: ReactNode;
|
||||
};
|
||||
|
||||
export function AuthLayout({
|
||||
brand = APP_NAME,
|
||||
title,
|
||||
lead,
|
||||
cta,
|
||||
footer,
|
||||
flash,
|
||||
}: Props) {
|
||||
return (
|
||||
<main className="auth-layout">
|
||||
<div className="auth-panel">
|
||||
<AppText variant="footnote" tone="accent">
|
||||
{brand}
|
||||
</AppText>
|
||||
<AppText variant="largeTitle" as="h1">
|
||||
{title}
|
||||
</AppText>
|
||||
<AppText variant="subhead">{lead}</AppText>
|
||||
<div className="auth-panel__cta">{cta}</div>
|
||||
{flash}
|
||||
{footer ? <div className="auth-panel__foot">{footer}</div> : null}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export const PRIMARY_NAV = [
|
||||
{ id: "overview", to: "/", label: "Обзор", soon: false },
|
||||
{ id: "guides", to: "/guides", label: "Инструкции", soon: false },
|
||||
{ id: "styles", to: "/styles", label: "Стили", soon: false },
|
||||
{ id: "tokens", to: "/tokens", label: "Токены", soon: false },
|
||||
] as const;
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
tone?: "warn";
|
||||
};
|
||||
|
||||
export function Banner({ children, tone = "warn" }: Props) {
|
||||
return (
|
||||
<p className={`banner banner--${tone}`} role="status">
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
||||
import { AppButton, type AppButtonSize, type AppButtonStyle } from "../../ui";
|
||||
|
||||
type Variant = "primary" | "secondary" | "ghost" | "destructive";
|
||||
type Size = "md" | "sm";
|
||||
|
||||
const STYLE: Record<Variant, AppButtonStyle> = {
|
||||
primary: "filled",
|
||||
secondary: "gray",
|
||||
ghost: "plain",
|
||||
destructive: "destructive",
|
||||
};
|
||||
|
||||
const SIZE: Record<Size, AppButtonSize> = {
|
||||
md: "medium",
|
||||
sm: "small",
|
||||
};
|
||||
|
||||
type Props = ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
variant?: Variant;
|
||||
size?: Size;
|
||||
expanded?: boolean;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function Button({
|
||||
variant = "secondary",
|
||||
size = "md",
|
||||
expanded = false,
|
||||
className = "",
|
||||
children,
|
||||
...rest
|
||||
}: Props) {
|
||||
return (
|
||||
<AppButton
|
||||
buttonStyle={STYLE[variant]}
|
||||
size={SIZE[size]}
|
||||
expanded={expanded}
|
||||
className={className}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</AppButton>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { AppEmptyView } from "../../ui";
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function EmptyState({ children }: Props) {
|
||||
return <AppEmptyView message={children} />;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { InputHTMLAttributes, ReactNode } from "react";
|
||||
import { AppTextField } from "../../ui";
|
||||
|
||||
type Props = InputHTMLAttributes<HTMLInputElement> & {
|
||||
label: string;
|
||||
id: string;
|
||||
hint?: ReactNode;
|
||||
};
|
||||
|
||||
export function Field({ label, id, hint, className = "", ...rest }: Props) {
|
||||
return <AppTextField id={id} label={label} hint={hint} className={className} {...rest} />;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
type Props = {
|
||||
error?: string | null;
|
||||
notice?: string | null;
|
||||
};
|
||||
|
||||
export function Flash({ error = null, notice = null }: Props) {
|
||||
if (!error && !notice) return null;
|
||||
return (
|
||||
<div className="flash" aria-live="polite">
|
||||
{error ? <p className="flash__item flash__item--error">{error}</p> : null}
|
||||
{notice ? <p className="flash__item flash__item--ok">{notice}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { AppListSection, AppListTile } from "../../ui";
|
||||
|
||||
export type MetricItem = {
|
||||
label: string;
|
||||
value: ReactNode;
|
||||
warn?: boolean;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
items: MetricItem[];
|
||||
};
|
||||
|
||||
export function MetricGrid({ items }: Props) {
|
||||
return (
|
||||
<AppListSection header="Сводка">
|
||||
{items.map((item) => (
|
||||
<AppListTile
|
||||
key={item.label}
|
||||
title={item.label}
|
||||
value={<span className={item.warn ? "is-warn" : undefined}>{item.value}</span>}
|
||||
/>
|
||||
))}
|
||||
</AppListSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { AppText } from "../../ui";
|
||||
|
||||
type Props = {
|
||||
eyebrow?: string;
|
||||
title: string;
|
||||
description?: ReactNode;
|
||||
};
|
||||
|
||||
export function PageHeader({ eyebrow, title, description }: Props) {
|
||||
return (
|
||||
<header className="page-header">
|
||||
{eyebrow ? <AppText variant="footnote">{eyebrow}</AppText> : null}
|
||||
<AppText variant="largeTitle" as="h1">
|
||||
{title}
|
||||
</AppText>
|
||||
{description ? <AppText variant="subhead">{description}</AppText> : null}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { AppProgress, AppText } from "../../ui";
|
||||
|
||||
type Props = {
|
||||
spent: number;
|
||||
total: number;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export function ProgressBar({ spent, total, label }: Props) {
|
||||
const pct = total > 0 ? Math.min(100, Math.max(0, (spent / total) * 100)) : 0;
|
||||
return (
|
||||
<div className="progress">
|
||||
<AppProgress spent={spent} total={total} label={label} />
|
||||
<AppText variant="footnote">{Math.round(pct)}% бюджета использовано</AppText>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { AppSectionHeader } from "../../ui";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
description?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
children: ReactNode;
|
||||
labelledBy?: string;
|
||||
};
|
||||
|
||||
export function Section({ title, description, actions, children, labelledBy }: Props) {
|
||||
const id = labelledBy ?? `section-${title.toLowerCase().replace(/\s+/g, "-")}`;
|
||||
return (
|
||||
<section className="section" aria-labelledby={id}>
|
||||
<div className={`section__head${actions ? " section__head--row" : ""}`}>
|
||||
<div>
|
||||
<h2 id={id} className="section__title">
|
||||
<AppSectionHeader>{title}</AppSectionHeader>
|
||||
</h2>
|
||||
{description ? <p className="section__desc">{description}</p> : null}
|
||||
</div>
|
||||
{actions}
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export { Button } from "./Button";
|
||||
export { Field } from "./Field";
|
||||
export { Flash } from "./Flash";
|
||||
export { ProgressBar } from "./ProgressBar";
|
||||
export { MetricGrid } from "./MetricGrid";
|
||||
export { PageHeader } from "./PageHeader";
|
||||
export { Section } from "./Section";
|
||||
export { EmptyState } from "./EmptyState";
|
||||
export { Banner } from "./Banner";
|
||||
export * from "../../ui";
|
||||
@@ -0,0 +1,70 @@
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-body);
|
||||
letter-spacing: -0.41px;
|
||||
color: var(--label);
|
||||
background: var(--grouped-background);
|
||||
line-height: 1.3;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
button,
|
||||
input {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.is-warn {
|
||||
color: var(--color-warn) !important;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,993 @@
|
||||
/* —— Layout shell —— */
|
||||
.app-shell {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
grid-template-columns: var(--sidebar-width) minmax(0, 1fr);
|
||||
grid-template-rows: auto 1fr;
|
||||
grid-template-areas:
|
||||
"header header"
|
||||
"nav main";
|
||||
background: var(--grouped-background);
|
||||
}
|
||||
|
||||
.app-shell__header {
|
||||
grid-area: header;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
min-height: 44px;
|
||||
padding: 0 var(--gutter);
|
||||
border-bottom: var(--hairline) solid var(--separator);
|
||||
background: var(--bar-background);
|
||||
backdrop-filter: saturate(180%) blur(20px);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: var(--z-header);
|
||||
}
|
||||
|
||||
.app-shell__brand {
|
||||
font-size: var(--text-md);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.app-shell__user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.app-shell__chip {
|
||||
max-width: 12rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--color-muted);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.app-shell__nav {
|
||||
grid-area: nav;
|
||||
padding: var(--space-2) 0 var(--space-4);
|
||||
position: sticky;
|
||||
top: 44px;
|
||||
height: calc(100vh - 44px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.app-shell__nav-label {
|
||||
margin: 0 var(--space-2) var(--space-2);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.app-shell__nav-list {
|
||||
list-style: none;
|
||||
margin: 0 var(--gutter);
|
||||
padding: 0;
|
||||
display: grid;
|
||||
background: var(--grouped-surface);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-shell__nav-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
min-height: var(--row-min-height);
|
||||
padding: 0 var(--gutter);
|
||||
color: var(--label);
|
||||
font-size: var(--text-body);
|
||||
letter-spacing: -0.41px;
|
||||
}
|
||||
|
||||
.app-shell__nav-link:hover {
|
||||
background: var(--system-gray5);
|
||||
}
|
||||
|
||||
.app-shell__nav-link.is-active {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.app-shell__nav-link.is-disabled {
|
||||
opacity: 0.45;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.app-shell__badge {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.app-shell__main {
|
||||
grid-area: main;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.app-shell--workspace .app-shell__main {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.page {
|
||||
width: min(var(--content-max), 100%);
|
||||
margin: 0 auto;
|
||||
padding: var(--space-5) 0 var(--space-8);
|
||||
}
|
||||
|
||||
.page > .app-text,
|
||||
.page > .app-btn {
|
||||
margin-inline: var(--gutter);
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: var(--space-5);
|
||||
padding: 0 var(--gutter);
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.page-header__eyebrow {
|
||||
margin: 0 0 var(--space-2);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.page-header__title {
|
||||
font-size: var(--text-xl);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.page-header__desc {
|
||||
margin-top: var(--space-2);
|
||||
color: var(--color-muted);
|
||||
font-size: var(--text-md);
|
||||
max-width: 36rem;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin: 0 0 var(--space-6);
|
||||
}
|
||||
|
||||
.section:first-child {
|
||||
border-top: 0;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.section__head {
|
||||
margin-bottom: var(--space-2);
|
||||
padding-right: var(--gutter);
|
||||
}
|
||||
|
||||
.section__head--row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.section__title {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.section__title .app-section-header {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.section__desc {
|
||||
margin: 0;
|
||||
padding: 0 var(--gutter);
|
||||
color: var(--secondary-label);
|
||||
font-size: var(--text-footnote);
|
||||
}
|
||||
|
||||
/* —— Auth layout —— */
|
||||
.auth-layout {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: var(--space-5);
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.auth-panel {
|
||||
width: min(400px, 100%);
|
||||
padding: var(--space-6) var(--gutter);
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.auth-panel__brand {
|
||||
margin: 0 0 var(--space-5);
|
||||
color: var(--color-accent);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.auth-panel__title {
|
||||
font-size: var(--text-xl);
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.auth-panel__lead {
|
||||
margin-top: var(--space-2);
|
||||
color: var(--color-muted);
|
||||
font-size: var(--text-md);
|
||||
}
|
||||
|
||||
.auth-panel__cta {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
margin-top: var(--space-5);
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
.auth-divider {
|
||||
margin: 0;
|
||||
color: var(--color-muted);
|
||||
font-size: var(--text-sm);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.auth-panel__foot {
|
||||
margin-top: var(--space-5);
|
||||
padding-top: var(--space-4);
|
||||
border-top: 1px solid var(--color-line);
|
||||
color: var(--color-muted);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.auth-panel__foot code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.85em;
|
||||
color: var(--color-ink);
|
||||
}
|
||||
|
||||
.tg-login {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.btn--yandex {
|
||||
width: 100%;
|
||||
gap: var(--space-2);
|
||||
background: #000;
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn--yandex:hover:not(:disabled) {
|
||||
background: #1a1a1a;
|
||||
color: #fff;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.btn--yandex__mark {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border-radius: 8px;
|
||||
background: #fc3f1d;
|
||||
color: #fff;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* —— UI primitives —— */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: var(--control-height-md);
|
||||
padding: 0 var(--space-4);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 0;
|
||||
font-weight: 600;
|
||||
font-size: var(--text-body);
|
||||
cursor: pointer;
|
||||
background: var(--system-gray5);
|
||||
color: var(--label);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn--primary {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn--primary:hover:not(:disabled) {
|
||||
filter: brightness(1.04);
|
||||
}
|
||||
|
||||
.btn--secondary {
|
||||
background: var(--system-gray5);
|
||||
color: var(--label);
|
||||
}
|
||||
|
||||
.btn--ghost {
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.btn--sm {
|
||||
height: var(--control-height-sm);
|
||||
padding: 0 var(--space-3);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.field {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.field__label {
|
||||
color: var(--color-ink-secondary);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.field__control {
|
||||
width: 100%;
|
||||
height: var(--control-height-md);
|
||||
border: 0;
|
||||
background: var(--grouped-surface);
|
||||
color: var(--label);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0 var(--space-3);
|
||||
}
|
||||
|
||||
.field__control:hover:not(:disabled) {
|
||||
border-color: var(--color-ink-secondary);
|
||||
}
|
||||
|
||||
.field__control:disabled {
|
||||
background: var(--color-bg);
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.form {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
padding-inline: var(--gutter);
|
||||
}
|
||||
|
||||
.form__row {
|
||||
display: grid;
|
||||
grid-template-columns: 1.4fr 1fr;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.form__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.data-list__row .form__actions {
|
||||
flex-shrink: 0;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.check {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-2);
|
||||
color: var(--color-ink-secondary);
|
||||
font-size: var(--text-md);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.check input {
|
||||
margin-top: 0.15rem;
|
||||
accent-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.flash {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.flash {
|
||||
margin-inline: var(--gutter);
|
||||
}
|
||||
|
||||
.flash__item {
|
||||
margin: 0;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: var(--text-subhead);
|
||||
}
|
||||
|
||||
.flash__item--error {
|
||||
color: var(--color-danger);
|
||||
background: var(--color-danger-bg);
|
||||
border-color: #e2bcbc;
|
||||
}
|
||||
|
||||
.flash__item--ok {
|
||||
color: var(--color-ok);
|
||||
background: var(--color-ok-bg);
|
||||
border-color: #b9d8c7;
|
||||
}
|
||||
|
||||
.banner {
|
||||
margin: var(--space-4) var(--gutter) 0;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: var(--text-subhead);
|
||||
}
|
||||
|
||||
.banner--warn {
|
||||
color: var(--color-warn);
|
||||
background: var(--color-warn-bg);
|
||||
border-color: #e2d4a8;
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
margin: var(--space-5) 0 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
border: 1px solid var(--color-line);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.metric-grid > div {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-right: 1px solid var(--color-line);
|
||||
}
|
||||
|
||||
.metric-grid > div:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.metric-grid dt {
|
||||
margin: 0;
|
||||
color: var(--color-muted);
|
||||
font-size: var(--text-xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.metric-grid dd {
|
||||
margin: var(--space-1) 0 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-base);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.progress {
|
||||
margin: var(--space-4) var(--gutter) 0;
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.progress__track {
|
||||
height: 4px;
|
||||
background: var(--color-line);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress__bar {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--color-accent);
|
||||
}
|
||||
|
||||
.progress__meta {
|
||||
margin-top: var(--space-2);
|
||||
font-size: var(--text-xs);
|
||||
font-family: var(--font-mono);
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.hero-metric__label {
|
||||
margin-top: var(--space-4);
|
||||
color: var(--color-muted);
|
||||
font-size: var(--text-xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.hero-metric__label,
|
||||
.hero-metric__value {
|
||||
padding-inline: var(--gutter);
|
||||
}
|
||||
|
||||
.meta-row {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 500;
|
||||
color: var(--color-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.meta-row__dot {
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-line-strong);
|
||||
}
|
||||
|
||||
.data-list {
|
||||
list-style: none;
|
||||
margin: 0 var(--gutter);
|
||||
padding: 0;
|
||||
background: var(--grouped-surface);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.data-list__row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
align-items: baseline;
|
||||
min-height: var(--row-min-height);
|
||||
padding: 10px var(--gutter);
|
||||
box-shadow: inset 0 var(--hairline) 0 var(--separator);
|
||||
}
|
||||
|
||||
.data-list__row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.data-list__primary {
|
||||
display: block;
|
||||
font-size: var(--text-body);
|
||||
font-weight: 400;
|
||||
letter-spacing: -0.41px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.data-list__secondary {
|
||||
display: block;
|
||||
margin-top: 0.1rem;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.data-list__meta {
|
||||
color: var(--color-muted);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
margin: 0 var(--gutter);
|
||||
padding: var(--space-6) var(--gutter);
|
||||
color: var(--secondary-label);
|
||||
font-size: var(--text-subhead);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pager {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
margin-top: var(--space-4);
|
||||
padding-inline: var(--gutter);
|
||||
font-size: var(--text-footnote);
|
||||
}
|
||||
|
||||
.panel {
|
||||
border: 1px solid var(--color-line);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.panel__head {
|
||||
padding: var(--space-4);
|
||||
border-bottom: 1px solid var(--color-line);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.panel__body {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.coming-soon {
|
||||
border: 1px solid var(--color-line);
|
||||
background: var(--color-surface);
|
||||
padding: var(--space-6);
|
||||
}
|
||||
|
||||
.coming-soon__title {
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
|
||||
.coming-soon__text {
|
||||
margin-top: var(--space-2);
|
||||
color: var(--color-muted);
|
||||
font-size: var(--text-md);
|
||||
max-width: 28rem;
|
||||
}
|
||||
|
||||
.skel {
|
||||
background: var(--color-line);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.skel-line {
|
||||
height: 0.75rem;
|
||||
}
|
||||
|
||||
.skel-title {
|
||||
height: 2rem;
|
||||
width: min(220px, 55%);
|
||||
margin: var(--space-3) 0;
|
||||
}
|
||||
|
||||
.w-20 {
|
||||
width: 4.5rem;
|
||||
}
|
||||
.w-24 {
|
||||
width: 5.5rem;
|
||||
}
|
||||
.w-32 {
|
||||
width: 7rem;
|
||||
}
|
||||
.w-56 {
|
||||
width: 12rem;
|
||||
}
|
||||
.mt-2 {
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.field__hint {
|
||||
margin: 0;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.day-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.day-chip {
|
||||
min-width: 2.5rem;
|
||||
height: 28px;
|
||||
padding: 0 var(--space-3);
|
||||
border: 0;
|
||||
border-radius: var(--radius-capsule);
|
||||
background: var(--system-gray5);
|
||||
color: var(--label);
|
||||
font-size: var(--text-subhead);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.day-chip:hover:not(:disabled) {
|
||||
border-color: var(--color-ink-secondary);
|
||||
}
|
||||
|
||||
.day-chip.is-on {
|
||||
background: rgb(18 136 90 / 15%);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.day-chip:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.choice-set {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.choice-set legend {
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.split-block {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.app-shell {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-areas:
|
||||
"header"
|
||||
"nav"
|
||||
"main";
|
||||
}
|
||||
|
||||
.app-shell__nav {
|
||||
position: static;
|
||||
height: auto;
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--color-line);
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.app-shell__nav-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-shell__nav-list {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
overflow-x: auto;
|
||||
background: transparent;
|
||||
margin: 0 var(--gutter);
|
||||
}
|
||||
|
||||
.app-shell__nav-link {
|
||||
white-space: nowrap;
|
||||
background: var(--system-gray5);
|
||||
border-radius: var(--radius-capsule);
|
||||
min-height: 32px;
|
||||
padding: 0 12px;
|
||||
font-size: var(--text-subhead);
|
||||
}
|
||||
|
||||
.app-shell__nav-link.is-active {
|
||||
background: var(--grouped-surface);
|
||||
}
|
||||
|
||||
.page {
|
||||
width: min(var(--content-max), calc(100% - 2 * var(--space-4)));
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.metric-grid > div:nth-child(2) {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.metric-grid > div:nth-child(3),
|
||||
.metric-grid > div:nth-child(4) {
|
||||
border-top: 1px solid var(--color-line);
|
||||
}
|
||||
|
||||
.form__row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.section__head--row {
|
||||
flex-direction: column;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.cal__day {
|
||||
min-height: 3.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* —— Calendar —— */
|
||||
.cal {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.cal__weekdays,
|
||||
.cal__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.cal__weekday {
|
||||
text-align: center;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-muted);
|
||||
padding: var(--space-1) 0;
|
||||
}
|
||||
|
||||
.cal__day {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 2px;
|
||||
min-height: 4.25rem;
|
||||
padding: var(--space-2) var(--space-2) var(--space-1);
|
||||
margin: 0;
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--grouped-surface);
|
||||
color: var(--label);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.cal__day:hover {
|
||||
border-color: var(--color-line-strong);
|
||||
}
|
||||
|
||||
.cal__day--out {
|
||||
opacity: 0.42;
|
||||
}
|
||||
|
||||
.cal__day--today {
|
||||
border-color: var(--color-accent);
|
||||
background: var(--color-accent-soft);
|
||||
}
|
||||
|
||||
.cal__day--selected {
|
||||
outline: 2px solid var(--color-ink);
|
||||
outline-offset: -1px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.cal__day-num {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.cal__day-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.cal__spend-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-ink);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cal__spend-sum {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--color-ink-secondary);
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.cal__day--spent .cal__spend-sum {
|
||||
color: var(--color-ink);
|
||||
}
|
||||
|
||||
.cal-day-block + .cal-day-block {
|
||||
margin-top: var(--space-4);
|
||||
padding-top: var(--space-4);
|
||||
border-top: 1px solid var(--color-line);
|
||||
}
|
||||
|
||||
.cal-day-block__title {
|
||||
margin: 0 0 var(--space-2);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.cal-legend__swatch--spend {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-ink);
|
||||
margin-top: 5px;
|
||||
margin-left: 4px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.cal__stripes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.cal__stripe {
|
||||
display: block;
|
||||
height: 5px;
|
||||
border-radius: 1px;
|
||||
flex: 1;
|
||||
min-height: 4px;
|
||||
max-height: 8px;
|
||||
}
|
||||
|
||||
.cal__pays {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
min-height: 8px;
|
||||
}
|
||||
|
||||
.cal__pay {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
border: 1.5px solid var(--color-surface);
|
||||
box-shadow: 0 0 0 1px rgba(18, 23, 20, 0.2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cal-legend {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.cal-legend__item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-ink-secondary);
|
||||
}
|
||||
|
||||
.cal-legend__swatch {
|
||||
display: inline-block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.cal-legend__swatch--budget {
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.cal-legend__swatch--pay {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
margin-top: 4px;
|
||||
margin-left: 2px;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.cal-legend__meta {
|
||||
color: var(--color-muted);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
@import "./tokens.css";
|
||||
@import "./base.css";
|
||||
@import "../ui/kit.css";
|
||||
@import "./components.css";
|
||||
@import "../legal/legal.css";
|
||||
@@ -0,0 +1,117 @@
|
||||
:root {
|
||||
/* 4pt grid — same as mobile/lib/theme/tokens.dart */
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--space-4: 16px;
|
||||
--space-5: 20px;
|
||||
--space-6: 28px;
|
||||
--space-7: 40px;
|
||||
--space-8: 56px;
|
||||
--gutter: 16px;
|
||||
|
||||
/* SF Pro scale (Apple HIG), rendered in Inter */
|
||||
--text-caption2: 11px;
|
||||
--text-caption1: 12px;
|
||||
--text-footnote: 13px;
|
||||
--text-subhead: 15px;
|
||||
--text-callout: 16px;
|
||||
--text-body: 17px;
|
||||
--text-headline: 17px;
|
||||
--text-title3: 20px;
|
||||
--text-title2: 22px;
|
||||
--text-title1: 28px;
|
||||
--text-large-title: 34px;
|
||||
|
||||
/* Legacy aliases used by older product CSS */
|
||||
--text-xs: var(--text-caption2);
|
||||
--text-sm: var(--text-footnote);
|
||||
--text-md: var(--text-subhead);
|
||||
--text-base: var(--text-body);
|
||||
--text-lg: var(--text-title3);
|
||||
--text-xl: var(--text-title2);
|
||||
--text-2xl: var(--text-large-title);
|
||||
|
||||
/* Light — CupertinoDynamicColor.color */
|
||||
--accent: #12885a;
|
||||
--accent-soft: #e4f4ec;
|
||||
--system-red: #ff3b30;
|
||||
--system-orange: #ff9500;
|
||||
--system-green: #34c759;
|
||||
--system-gray: #8e8e93;
|
||||
--system-gray3: #c7c7cc;
|
||||
--system-gray5: #e5e5ea;
|
||||
--system-gray6: #f2f2f7;
|
||||
--label: #000000;
|
||||
--secondary-label: rgb(60 60 67 / 60%);
|
||||
--tertiary-label: rgb(60 60 67 / 30%);
|
||||
--separator: rgb(60 60 67 / 29%);
|
||||
--opaque-separator: #c6c6c8;
|
||||
--grouped-background: #f2f2f7;
|
||||
--grouped-surface: #ffffff;
|
||||
--bar-background: rgb(249 249 249 / 94%);
|
||||
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 10px;
|
||||
--radius-lg: 12px;
|
||||
--radius-xl: 16px;
|
||||
--radius-capsule: 999px;
|
||||
|
||||
--control-height: 50px;
|
||||
--control-height-md: 44px;
|
||||
--control-height-sm: 34px;
|
||||
--row-min-height: 44px;
|
||||
--hairline: 0.5px;
|
||||
|
||||
--shell-max: 960px;
|
||||
--content-max: 720px;
|
||||
--sidebar-width: 240px;
|
||||
|
||||
--font-sans: "Inter", "SF Pro Text", "Segoe UI", sans-serif;
|
||||
--font-mono: "Inter", ui-monospace, monospace;
|
||||
|
||||
--shadow-none: none;
|
||||
--z-header: 20;
|
||||
--z-sidebar: 15;
|
||||
--z-toast: 40;
|
||||
|
||||
/* Backward-compatible names from the previous system UI */
|
||||
--color-ink: var(--label);
|
||||
--color-ink-secondary: var(--secondary-label);
|
||||
--color-muted: var(--secondary-label);
|
||||
--color-line: var(--separator);
|
||||
--color-line-strong: var(--opaque-separator);
|
||||
--color-bg: var(--grouped-background);
|
||||
--color-surface: var(--grouped-surface);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-hover: #0e6f4a;
|
||||
--color-accent-soft: var(--accent-soft);
|
||||
--color-warn: var(--system-orange);
|
||||
--color-warn-bg: rgb(255 149 0 / 12%);
|
||||
--color-danger: var(--system-red);
|
||||
--color-danger-bg: rgb(255 59 48 / 12%);
|
||||
--color-ok: var(--system-green);
|
||||
--color-ok-bg: rgb(52 199 89 / 12%);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--accent: #3cd68c;
|
||||
--accent-soft: #14301f;
|
||||
--system-red: #ff453a;
|
||||
--system-orange: #ff9f0a;
|
||||
--system-green: #30d158;
|
||||
--system-gray3: #48484a;
|
||||
--system-gray5: #2c2c2e;
|
||||
--system-gray6: #1c1c1e;
|
||||
--label: #ffffff;
|
||||
--secondary-label: rgb(235 235 245 / 60%);
|
||||
--tertiary-label: rgb(235 235 245 / 30%);
|
||||
--separator: rgb(84 84 88 / 65%);
|
||||
--opaque-separator: #38383a;
|
||||
--grouped-background: #000000;
|
||||
--grouped-surface: #1c1c1e;
|
||||
--bar-background: rgb(29 29 29 / 94%);
|
||||
--color-accent-hover: #5ee0a2;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { api, setPreviewToken, type Me } from "../../api";
|
||||
import { yandexRedirectUri } from "../../auth/yandexRedirect";
|
||||
|
||||
type AuthState = {
|
||||
user: Me | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refresh: () => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
};
|
||||
|
||||
const AuthContext = createContext<AuthState | null>(null);
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<Me | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refresh = async () => {
|
||||
try {
|
||||
setUser(await api.me());
|
||||
setError(null);
|
||||
} catch {
|
||||
setUser(null);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const code = params.get("code");
|
||||
const state = params.get("state");
|
||||
|
||||
const boot = async () => {
|
||||
try {
|
||||
if (code) {
|
||||
const redirectUri =
|
||||
sessionStorage.getItem("ts.oauthRedirect") ?? yandexRedirectUri("https://tile-server.ru");
|
||||
const auth = await api.loginYandex(code, redirectUri, state);
|
||||
sessionStorage.removeItem("ts.oauthState");
|
||||
sessionStorage.removeItem("ts.oauthRedirect");
|
||||
setUser(auth.user);
|
||||
if (auth.cabinetToken) {
|
||||
setPreviewToken(auth.cabinetToken);
|
||||
}
|
||||
window.history.replaceState({}, "", "/");
|
||||
} else {
|
||||
await refresh();
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Ошибка входа");
|
||||
setUser(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
void boot();
|
||||
}, []);
|
||||
|
||||
const logout = async () => {
|
||||
await api.logout();
|
||||
setUser(null);
|
||||
};
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ user, loading, error, refresh, logout }),
|
||||
[user, loading, error]
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth(): AuthState {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) {
|
||||
throw new Error("AuthProvider is required");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Navigate } from "react-router-dom";
|
||||
import { api } from "../../api";
|
||||
import { yandexRedirectUri } from "../../auth/yandexRedirect";
|
||||
import { AuthLayout } from "../../components/layout/AuthLayout";
|
||||
import { Flash } from "../../components/ui";
|
||||
import { LegalCheckboxes } from "../../legal/LegalCheckboxes";
|
||||
import { SiteFooter } from "../../legal/SiteFooter";
|
||||
import { useAuth } from "./AuthContext";
|
||||
import { YandexLoginButton } from "./YandexLoginButton";
|
||||
|
||||
export function LoginPage() {
|
||||
const { user, loading, error } = useAuth();
|
||||
const [yandexClientId, setYandexClientId] = useState<string | null>(null);
|
||||
const [configuredRedirect, setConfiguredRedirect] = useState<string | null>(null);
|
||||
const [providerError, setProviderError] = useState<string | null>(null);
|
||||
const [offerAccepted, setOfferAccepted] = useState(false);
|
||||
const [consentAccepted, setConsentAccepted] = useState(false);
|
||||
const redirectUri = yandexRedirectUri(configuredRedirect);
|
||||
const accepted = offerAccepted && consentAccepted;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void api
|
||||
.providers()
|
||||
.then((providers) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
const yandex = providers.yandex;
|
||||
const id = yandex?.enabled ? yandex.clientId : null;
|
||||
setYandexClientId(id && id.length > 0 ? id : null);
|
||||
setConfiguredRedirect(yandex?.redirectUri ?? null);
|
||||
if (id) {
|
||||
sessionStorage.setItem("ts.oauthRedirect", yandexRedirectUri(yandex.redirectUri));
|
||||
}
|
||||
if (!yandex?.enabled) {
|
||||
setProviderError("Яндекс OAuth не настроен. Задайте YANDEX_CLIENT_SECRET в .env на сервере.");
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!cancelled) {
|
||||
setYandexClientId(null);
|
||||
setProviderError(err instanceof Error ? err.message : "Не удалось проверить вход через Яндекс");
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return <p className="muted">Загрузка…</p>;
|
||||
}
|
||||
|
||||
if (user) {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthLayout
|
||||
title="Личный кабинет тайлов"
|
||||
lead="Вход через Яндекс ID. Стили MapLibre, персональные токены и учёт запросов в одном месте."
|
||||
cta={
|
||||
yandexClientId ? (
|
||||
<>
|
||||
<LegalCheckboxes
|
||||
offer={offerAccepted}
|
||||
consent={consentAccepted}
|
||||
onOffer={setOfferAccepted}
|
||||
onConsent={setConsentAccepted}
|
||||
/>
|
||||
<YandexLoginButton
|
||||
clientId={yandexClientId}
|
||||
redirectUri={redirectUri}
|
||||
disabled={!accepted}
|
||||
/>
|
||||
</>
|
||||
) : null
|
||||
}
|
||||
flash={<Flash error={error ?? providerError} notice={null} />}
|
||||
footer={<SiteFooter />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { yandexAuthorizeUrl } from "../../auth/yandexRedirect";
|
||||
|
||||
type Props = {
|
||||
clientId: string;
|
||||
redirectUri: string;
|
||||
state?: string | null;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export function YandexLoginButton({
|
||||
clientId,
|
||||
redirectUri,
|
||||
state,
|
||||
disabled = false,
|
||||
}: Props) {
|
||||
const href = yandexAuthorizeUrl(clientId, redirectUri, state);
|
||||
const className = `app-btn app-btn--large app-btn--expanded btn--yandex${disabled ? " is-disabled" : ""}`;
|
||||
|
||||
if (disabled) {
|
||||
return (
|
||||
<span className={className} aria-disabled="true">
|
||||
<span className="btn--yandex__mark" aria-hidden="true">
|
||||
Я
|
||||
</span>
|
||||
Войти через Яндекс
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<a className={className} href={href}>
|
||||
<span className="btn--yandex__mark" aria-hidden="true">
|
||||
Я
|
||||
</span>
|
||||
Войти через Яндекс
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { api, getPreviewToken, setPreviewToken, type SourceItem } from "../../api";
|
||||
import { Button, Flash, PageHeader } from "../../components/ui";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { StylePreview } from "../styles/StylePreview";
|
||||
import "./guides.css";
|
||||
|
||||
function CopyBlock({ label, value }: { label: string; value: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
return (
|
||||
<figure className="guide-code">
|
||||
<figcaption>
|
||||
<span>{label}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void navigator.clipboard.writeText(value).then(() => {
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1400);
|
||||
});
|
||||
}}
|
||||
>
|
||||
{copied ? "скопировано" : "копировать"}
|
||||
</button>
|
||||
</figcaption>
|
||||
<pre>{value}</pre>
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
|
||||
export function GuidesPage() {
|
||||
const { user } = useAuth();
|
||||
const [sources, setSources] = useState<SourceItem[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [token, setToken] = useState(() => getPreviewToken());
|
||||
const source = sources[0]?.id ?? "central-fed-district";
|
||||
const origin = window.location.origin;
|
||||
const slug = user?.slug ?? "{slug}";
|
||||
|
||||
useEffect(() => {
|
||||
void api
|
||||
.sources()
|
||||
.then((list) => setSources(list.filter((item) => item.enabled)))
|
||||
.catch((err: unknown) => setError(err instanceof Error ? err.message : "Не удалось загрузить источники"));
|
||||
}, []);
|
||||
|
||||
const styleUrl = `${origin}/u/${slug}/styles/osm-bright?source=${source}${token ? `&token=${token}` : "&token=ts_…"}`;
|
||||
const tilesUrl = `${origin}/u/${slug}/tiles/${source}/{z}/{x}/{y}.pbf?token=${token ?? "ts_…"}`;
|
||||
|
||||
const html = useMemo(
|
||||
() => `<link href="https://unpkg.com/maplibre-gl@5.6.1/dist/maplibre-gl.css" rel="stylesheet" />
|
||||
<script src="https://unpkg.com/maplibre-gl@5.6.1/dist/maplibre-gl.js"></script>
|
||||
<div id="map" style="width:100%;height:420px"></div>
|
||||
<script>
|
||||
const map = new maplibregl.Map({
|
||||
container: "map",
|
||||
style: "${styleUrl}",
|
||||
center: [37.6173, 55.7558],
|
||||
zoom: 11
|
||||
});
|
||||
</script>`,
|
||||
[styleUrl]
|
||||
);
|
||||
|
||||
const react = useMemo(
|
||||
() => `import maplibregl from "maplibre-gl";
|
||||
import "maplibre-gl/dist/maplibre-gl.css";
|
||||
|
||||
const map = new maplibregl.Map({
|
||||
container: "map",
|
||||
style: "${styleUrl}",
|
||||
center: [37.6173, 55.7558],
|
||||
zoom: 11
|
||||
});`,
|
||||
[styleUrl]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="guides">
|
||||
<PageHeader
|
||||
eyebrow="Документация"
|
||||
title="Как подключить карту"
|
||||
description="Публичные /api/v1/styles и /api/v1/tiles закрыты. Клиенту нужен персональный URL и токен ts_…"
|
||||
/>
|
||||
<Flash error={error} />
|
||||
|
||||
<ol className="guide-steps">
|
||||
<li>
|
||||
<strong>1. Токен</strong>
|
||||
<p>В разделе «Токены» создайте ключ. Он показывается один раз и дальше уходит только в query <code>token=</code> или Bearer.</p>
|
||||
{!token ? (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
disabled={creating}
|
||||
onClick={() => {
|
||||
setCreating(true);
|
||||
void api
|
||||
.createToken("Превью")
|
||||
.then((created) => {
|
||||
setPreviewToken(created.token);
|
||||
setToken(created.token);
|
||||
})
|
||||
.catch((err: unknown) => setError(err instanceof Error ? err.message : "Не удалось создать токен"))
|
||||
.finally(() => setCreating(false));
|
||||
}}
|
||||
>
|
||||
Создать превью-токен
|
||||
</Button>
|
||||
) : (
|
||||
<p className="guide-ok">Превью-токен этой сессии уже есть.</p>
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
<strong>2. Стиль</strong>
|
||||
<p>
|
||||
Пресет <code>osm-bright</code> доступен сразу. Свой вид — клонируйте в{" "}
|
||||
<Link to="/styles">Стилях</Link> и откройте визуальный редактор.
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<strong>3. URL</strong>
|
||||
<p>MapLibre просит style JSON. Сервер сам подставит тайлы с тем же токеном.</p>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<CopyBlock label="Style JSON" value={styleUrl} />
|
||||
<CopyBlock label="Тайлы MVT" value={tilesUrl} />
|
||||
|
||||
<h2 className="guides__h">HTML</h2>
|
||||
<CopyBlock label="Минимальная страница" value={html} />
|
||||
|
||||
<h2 className="guides__h">React + MapLibre</h2>
|
||||
<CopyBlock label="Тот же стиль в приложении" value={react} />
|
||||
|
||||
<section className="guide-result">
|
||||
<div>
|
||||
<h2>Результат</h2>
|
||||
<p>Живая карта с вашего аккаунта. Если токена нет — сначала шаг 1.</p>
|
||||
</div>
|
||||
{user && token ? (
|
||||
<StylePreview slug={user.slug} name="osm-bright" source={source} token={token} nonce={0} />
|
||||
) : (
|
||||
<div className="guide-result__empty">Нужен токен, чтобы нарисовать карту.</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
.guides {
|
||||
width: min(880px, 100%);
|
||||
margin: 0 auto;
|
||||
padding: var(--space-5) 0 var(--space-8);
|
||||
}
|
||||
|
||||
.guides .page-header {
|
||||
padding-inline: var(--gutter);
|
||||
}
|
||||
|
||||
.guides__h {
|
||||
margin: var(--space-6) var(--gutter) var(--space-3);
|
||||
font-size: var(--text-title3);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.guide-steps {
|
||||
list-style: none;
|
||||
margin: 0 var(--gutter) var(--space-5);
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.guide-steps li {
|
||||
padding: var(--space-4);
|
||||
border-radius: var(--radius-xl);
|
||||
background: var(--grouped-surface);
|
||||
box-shadow: 0 1px 0 var(--separator);
|
||||
}
|
||||
|
||||
.guide-steps strong {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.guide-steps p {
|
||||
margin: 0 0 var(--space-3);
|
||||
color: var(--secondary-label);
|
||||
font-size: var(--text-subhead);
|
||||
}
|
||||
|
||||
.guide-steps code {
|
||||
font-family: ui-monospace, "Cascadia Mono", Consolas, monospace;
|
||||
font-size: 0.92em;
|
||||
}
|
||||
|
||||
.guide-ok {
|
||||
margin: 0;
|
||||
color: var(--accent);
|
||||
font-size: var(--text-footnote);
|
||||
}
|
||||
|
||||
.guide-code {
|
||||
margin: 0 var(--gutter) var(--space-4);
|
||||
border-radius: var(--radius-xl);
|
||||
overflow: hidden;
|
||||
background: #11161c;
|
||||
color: #e8eef4;
|
||||
}
|
||||
|
||||
.guide-code figcaption {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 14px;
|
||||
font-size: var(--text-caption1);
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: rgb(232 238 244 / 55%);
|
||||
border-bottom: 1px solid rgb(255 255 255 / 8%);
|
||||
}
|
||||
|
||||
.guide-code button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #3cd68c;
|
||||
cursor: pointer;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.guide-code pre {
|
||||
margin: 0;
|
||||
padding: 14px 16px 16px;
|
||||
overflow: auto;
|
||||
font-family: ui-monospace, "Cascadia Mono", Consolas, monospace;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.guide-result {
|
||||
margin: var(--space-6) var(--gutter) 0;
|
||||
padding: var(--space-4);
|
||||
border-radius: var(--radius-xl);
|
||||
background: var(--grouped-surface);
|
||||
}
|
||||
|
||||
.guide-result h2 {
|
||||
margin: 0 0 6px;
|
||||
font-size: var(--text-title3);
|
||||
}
|
||||
|
||||
.guide-result p {
|
||||
margin: 0 0 var(--space-4);
|
||||
color: var(--secondary-label);
|
||||
}
|
||||
|
||||
.guide-result .map-frame {
|
||||
height: 22rem;
|
||||
}
|
||||
|
||||
.guide-result__empty {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
height: 14rem;
|
||||
border-radius: 12px;
|
||||
background: var(--grouped-background);
|
||||
color: var(--secondary-label);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { fieldsForLayer } from "./layerFields";
|
||||
import {
|
||||
asColor,
|
||||
asNumber,
|
||||
isExpression,
|
||||
LAYER_TYPE_LABEL,
|
||||
toColorInput,
|
||||
type StyleLayer
|
||||
} from "./styleModel";
|
||||
|
||||
type Props = {
|
||||
layer: StyleLayer | null;
|
||||
readOnly: boolean;
|
||||
onPaint: (property: string, value: unknown) => void;
|
||||
onLayout: (property: string, value: unknown) => void;
|
||||
};
|
||||
|
||||
export function LayerInspector({ layer, readOnly, onPaint, onLayout }: Props) {
|
||||
if (!layer) {
|
||||
return (
|
||||
<aside className="style-pane style-pane--props">
|
||||
<header className="style-pane__head">
|
||||
<p className="style-pane__kicker">Свойства</p>
|
||||
<strong>Слой не выбран</strong>
|
||||
</header>
|
||||
<p className="style-pane__hint">Выберите слой справа или кликните объект на карте.</p>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
const fields = fieldsForLayer(layer.type);
|
||||
const hidden = layer.layout?.visibility === "none";
|
||||
|
||||
return (
|
||||
<aside className="style-pane style-pane--props">
|
||||
<header className="style-pane__head">
|
||||
<p className="style-pane__kicker">{LAYER_TYPE_LABEL[layer.type] ?? layer.type}</p>
|
||||
<strong>{layer.id}</strong>
|
||||
{layer["source-layer"] ? <span className="style-pane__meta">{layer["source-layer"]}</span> : null}
|
||||
</header>
|
||||
|
||||
<div className="prop-list">
|
||||
{fields.map((field) => {
|
||||
if (field.kind === "visibility") {
|
||||
return (
|
||||
<label key="visibility" className="prop">
|
||||
<span>Видимость</span>
|
||||
<button
|
||||
type="button"
|
||||
className={`prop__toggle${hidden ? "" : " is-on"}`}
|
||||
disabled={readOnly}
|
||||
onClick={() => onLayout("visibility", hidden ? "visible" : "none")}
|
||||
>
|
||||
{hidden ? "скрыт" : "виден"}
|
||||
</button>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
const raw = layer.paint?.[field.property];
|
||||
const complex = isExpression(raw) && asColor(raw) === null && asNumber(raw) === null;
|
||||
|
||||
if (field.kind === "color") {
|
||||
const color = asColor(raw);
|
||||
return (
|
||||
<label key={field.property} className="prop">
|
||||
<span>{field.label}</span>
|
||||
{complex ? (
|
||||
<div className="prop__complex">
|
||||
<em>формула</em>
|
||||
<button type="button" disabled={readOnly} onClick={() => onPaint(field.property, "#888888")}>
|
||||
заменить цветом
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="prop__color">
|
||||
<input
|
||||
type="color"
|
||||
disabled={readOnly}
|
||||
value={toColorInput(color ?? "#888888")}
|
||||
onChange={(e) => onPaint(field.property, e.target.value)}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
disabled={readOnly}
|
||||
value={color ?? ""}
|
||||
placeholder="#000000"
|
||||
onChange={(e) => onPaint(field.property, e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
const number = asNumber(raw);
|
||||
return (
|
||||
<label key={field.property} className="prop">
|
||||
<span>{field.label}</span>
|
||||
{complex || number === null ? (
|
||||
<div className="prop__complex">
|
||||
<em>{complex ? "по зуму" : "не задано"}</em>
|
||||
<input
|
||||
type="number"
|
||||
disabled={readOnly}
|
||||
min={field.min}
|
||||
max={field.max}
|
||||
step={field.step}
|
||||
placeholder={String(field.max > 1 ? 2 : 1)}
|
||||
onChange={(e) => {
|
||||
const next = Number(e.target.value);
|
||||
if (Number.isFinite(next)) {
|
||||
onPaint(field.property, next);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="prop__range">
|
||||
<input
|
||||
type="range"
|
||||
disabled={readOnly}
|
||||
min={field.min}
|
||||
max={field.max}
|
||||
step={field.step}
|
||||
value={number}
|
||||
onChange={(e) => onPaint(field.property, Number(e.target.value))}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
disabled={readOnly}
|
||||
min={field.min}
|
||||
max={field.max}
|
||||
step={field.step}
|
||||
value={number}
|
||||
onChange={(e) => onPaint(field.property, Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { LAYER_TYPE_LABEL, type StyleLayer } from "./styleModel";
|
||||
|
||||
type Props = {
|
||||
layers: StyleLayer[];
|
||||
selectedId: string | null;
|
||||
readOnly: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
onToggle: (id: string, visible: boolean) => void;
|
||||
};
|
||||
|
||||
export function LayerList({ layers, selectedId, readOnly, onSelect, onToggle }: Props) {
|
||||
const [query, setQuery] = useState("");
|
||||
const visible = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
const items = [...layers].reverse();
|
||||
if (!q) {
|
||||
return items;
|
||||
}
|
||||
return items.filter(
|
||||
(layer) =>
|
||||
layer.id.toLowerCase().includes(q) ||
|
||||
layer.type.toLowerCase().includes(q) ||
|
||||
(layer["source-layer"] ?? "").toLowerCase().includes(q)
|
||||
);
|
||||
}, [layers, query]);
|
||||
|
||||
return (
|
||||
<aside className="style-pane style-pane--layers">
|
||||
<header className="style-pane__head">
|
||||
<div>
|
||||
<p className="style-pane__kicker">Слои</p>
|
||||
<strong>{layers.length}</strong>
|
||||
</div>
|
||||
<input
|
||||
className="style-pane__search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Поиск"
|
||||
aria-label="Поиск слоя"
|
||||
/>
|
||||
</header>
|
||||
<ul className="layer-list">
|
||||
{visible.map((layer) => {
|
||||
const hidden = layer.layout?.visibility === "none";
|
||||
return (
|
||||
<li key={layer.id}>
|
||||
<button
|
||||
type="button"
|
||||
className={`layer-row${selectedId === layer.id ? " is-active" : ""}${hidden ? " is-hidden" : ""}`}
|
||||
onClick={() => onSelect(layer.id)}
|
||||
>
|
||||
<span className={`layer-row__type layer-row__type--${layer.type}`}>{LAYER_TYPE_LABEL[layer.type] ?? layer.type}</span>
|
||||
<span className="layer-row__id">{layer.id}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="layer-row__eye"
|
||||
disabled={readOnly}
|
||||
title={hidden ? "Показать" : "Скрыть"}
|
||||
onClick={() => onToggle(layer.id, hidden)}
|
||||
>
|
||||
{hidden ? "○" : "●"}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import maplibregl, { type Map as MapLibreMap, type StyleSpecification } from "maplibre-gl";
|
||||
import "maplibre-gl/dist/maplibre-gl.css";
|
||||
import { bindStyleForPreview, type MapStyle, type StyleEdit } from "./styleModel";
|
||||
|
||||
type Props = {
|
||||
style: MapStyle;
|
||||
tilesUrl: string;
|
||||
selectedLayerId: string | null;
|
||||
lastEdit: StyleEdit | null;
|
||||
styleEpoch: number;
|
||||
onSelectLayer: (layerId: string) => void;
|
||||
};
|
||||
|
||||
export function LiveMap({ style, tilesUrl, selectedLayerId, lastEdit, styleEpoch, onSelectLayer }: Props) {
|
||||
const el = useRef<HTMLDivElement | null>(null);
|
||||
const mapRef = useRef<MapLibreMap | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!el.current || !tilesUrl) {
|
||||
return;
|
||||
}
|
||||
const map = new maplibregl.Map({
|
||||
container: el.current,
|
||||
style: bindStyleForPreview(style, tilesUrl) as StyleSpecification,
|
||||
center: [37.6173, 55.7558],
|
||||
zoom: 11,
|
||||
maxZoom: 18,
|
||||
attributionControl: { compact: true, customAttribution: "© OpenStreetMap" },
|
||||
transformRequest: (url) => ({ url: new URL(url, window.location.origin).href })
|
||||
});
|
||||
map.addControl(new maplibregl.NavigationControl({ showCompass: false }), "top-right");
|
||||
map.on("click", (event) => {
|
||||
const hit = map.queryRenderedFeatures(event.point)[0];
|
||||
if (hit?.layer?.id) {
|
||||
onSelectLayer(hit.layer.id);
|
||||
}
|
||||
});
|
||||
mapRef.current = map;
|
||||
return () => {
|
||||
map.remove();
|
||||
mapRef.current = null;
|
||||
};
|
||||
// Recreate only when tiles source changes.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tilesUrl, styleEpoch]);
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map || !lastEdit || !map.isStyleLoaded() || !map.getLayer(lastEdit.layerId)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (lastEdit.target === "paint") {
|
||||
map.setPaintProperty(lastEdit.layerId, lastEdit.property, lastEdit.value);
|
||||
} else {
|
||||
map.setLayoutProperty(lastEdit.layerId, lastEdit.property, lastEdit.value);
|
||||
}
|
||||
} catch {
|
||||
map.setStyle(bindStyleForPreview(style, tilesUrl) as StyleSpecification, { diff: true });
|
||||
}
|
||||
}, [lastEdit, style, tilesUrl]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="live-map"
|
||||
ref={el}
|
||||
role="application"
|
||||
aria-label="Превью стиля"
|
||||
data-selected-layer={selectedLayerId ?? undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import { api, getPreviewToken, type SourceItem } from "../../api";
|
||||
import { Button, Flash } from "../../components/ui";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { LayerInspector } from "./LayerInspector";
|
||||
import { LayerList } from "./LayerList";
|
||||
import { LiveMap } from "./LiveMap";
|
||||
import {
|
||||
isMapStyle,
|
||||
tilesTemplate,
|
||||
updateLayer,
|
||||
type MapStyle,
|
||||
type StyleEdit,
|
||||
type StyleLayer
|
||||
} from "./styleModel";
|
||||
import "./editor.css";
|
||||
|
||||
export function StyleEditorPage() {
|
||||
const { name } = useParams<{ name: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const [style, setStyle] = useState<MapStyle | null>(null);
|
||||
const [sources, setSources] = useState<SourceItem[]>([]);
|
||||
const [source, setSource] = useState("");
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [lastEdit, setLastEdit] = useState<StyleEdit | null>(null);
|
||||
const [styleEpoch, setStyleEpoch] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [isPreset, setIsPreset] = useState(true);
|
||||
const token = getPreviewToken();
|
||||
|
||||
useEffect(() => {
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
void Promise.all([api.getStyle(name), api.styles(), api.sources()])
|
||||
.then(([raw, list, sourceList]) => {
|
||||
if (!isMapStyle(raw)) {
|
||||
throw new Error("Стиль должен быть объектом MapLibre (version 8).");
|
||||
}
|
||||
setStyle(raw);
|
||||
setIsPreset(list.find((item) => item.name === name)?.isPreset ?? true);
|
||||
const enabled = sourceList.filter((item) => item.enabled);
|
||||
setSources(enabled);
|
||||
setSource((current) => current || enabled[0]?.id || "");
|
||||
const first = raw.layers?.[raw.layers.length - 1]?.id ?? null;
|
||||
setSelectedId(first);
|
||||
setStyleEpoch((n) => n + 1);
|
||||
})
|
||||
.catch((err: unknown) => setError(err instanceof Error ? err.message : "Не удалось открыть стиль"));
|
||||
}, [name]);
|
||||
|
||||
const layers = style?.layers ?? [];
|
||||
const selected: StyleLayer | null = layers.find((layer) => layer.id === selectedId) ?? null;
|
||||
const tilesUrl = useMemo(() => {
|
||||
if (!user || !source || !token) {
|
||||
return "";
|
||||
}
|
||||
return tilesTemplate(user.slug, source, token);
|
||||
}, [user, source, token]);
|
||||
|
||||
const apply = (edit: StyleEdit) => {
|
||||
if (!style || isPreset) {
|
||||
return;
|
||||
}
|
||||
setStyle(updateLayer(style, edit));
|
||||
setLastEdit(edit);
|
||||
};
|
||||
|
||||
if (!name || !user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="style-workspace">
|
||||
<header className="style-workspace__bar">
|
||||
<div className="style-workspace__title">
|
||||
<Link to="/styles" className="style-workspace__back">
|
||||
Стили
|
||||
</Link>
|
||||
<h1>{name}</h1>
|
||||
<span className={`style-workspace__badge${isPreset ? " is-preset" : ""}`}>
|
||||
{isPreset ? "пресет" : "мой стиль"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="style-workspace__actions">
|
||||
<label className="style-workspace__source">
|
||||
<span>Источник</span>
|
||||
<select
|
||||
value={source}
|
||||
onChange={(e) => {
|
||||
setSource(e.target.value);
|
||||
setStyleEpoch((n) => n + 1);
|
||||
}}
|
||||
>
|
||||
{sources.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{!isPreset ? (
|
||||
<>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
disabled={saving || !style}
|
||||
onClick={() => {
|
||||
if (!style) {
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
void api
|
||||
.saveStyle(name, style)
|
||||
.then(() => setStyleEpoch((n) => n + 1))
|
||||
.catch((err: unknown) => setError(err instanceof Error ? err.message : "Не удалось сохранить"))
|
||||
.finally(() => setSaving(false));
|
||||
}}
|
||||
>
|
||||
Сохранить
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (!window.confirm(`Удалить стиль ${name}?`)) {
|
||||
return;
|
||||
}
|
||||
void api
|
||||
.deleteStyle(name)
|
||||
.then(() => navigate("/styles"))
|
||||
.catch((err: unknown) => setError(err instanceof Error ? err.message : "Не удалось удалить"));
|
||||
}}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button variant="primary" size="sm" onClick={() => navigate("/styles")}>
|
||||
Клонировать в списке
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{error ? (
|
||||
<div className="style-workspace__flash">
|
||||
<Flash error={error} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isPreset ? (
|
||||
<p className="style-workspace__note">Пресет только для просмотра. Склонируйте его в «Стили», чтобы править цвета и толщины.</p>
|
||||
) : null}
|
||||
|
||||
<div className="style-workspace__body">
|
||||
<LayerInspector
|
||||
layer={selected}
|
||||
readOnly={isPreset}
|
||||
onPaint={(property, value) => selected && apply({ layerId: selected.id, target: "paint", property, value })}
|
||||
onLayout={(property, value) => selected && apply({ layerId: selected.id, target: "layout", property, value })}
|
||||
/>
|
||||
<section className="style-workspace__map">
|
||||
{style && tilesUrl ? (
|
||||
<LiveMap
|
||||
style={style}
|
||||
tilesUrl={tilesUrl}
|
||||
selectedLayerId={selectedId}
|
||||
lastEdit={lastEdit}
|
||||
styleEpoch={styleEpoch}
|
||||
onSelectLayer={setSelectedId}
|
||||
/>
|
||||
) : (
|
||||
<div className="style-workspace__empty">
|
||||
{token
|
||||
? "Нет источника тайлов."
|
||||
: "Создайте токен в разделе «Токены» — без него превью не загрузит персональные тайлы."}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
<LayerList
|
||||
layers={layers}
|
||||
selectedId={selectedId}
|
||||
readOnly={isPreset}
|
||||
onSelect={setSelectedId}
|
||||
onToggle={(id, visible) => apply({ layerId: id, target: "layout", property: "visibility", value: visible ? "visible" : "none" })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import maplibregl from "maplibre-gl";
|
||||
import "maplibre-gl/dist/maplibre-gl.css";
|
||||
|
||||
type Props = {
|
||||
slug: string;
|
||||
name: string;
|
||||
source: string;
|
||||
token: string;
|
||||
nonce: number;
|
||||
};
|
||||
|
||||
export function StylePreview({ slug, name, source, token, nonce }: Props) {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current || !source) {
|
||||
return;
|
||||
}
|
||||
|
||||
const map = new maplibregl.Map({
|
||||
container: ref.current,
|
||||
style: `/u/${slug}/styles/${name}?source=${encodeURIComponent(source)}&token=${encodeURIComponent(token)}&n=${nonce}`,
|
||||
center: [37.6173, 55.7558],
|
||||
zoom: 10
|
||||
});
|
||||
|
||||
return () => map.remove();
|
||||
}, [slug, name, source, token, nonce]);
|
||||
|
||||
return <div className="map-frame" ref={ref} />;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { api, type StyleItem } from "../../api";
|
||||
import { Button, Flash, PageHeader, Section } from "../../components/ui";
|
||||
import { AppSelect, AppTextField } from "../../ui";
|
||||
|
||||
export function StylesPage() {
|
||||
const navigate = useNavigate();
|
||||
const [items, setItems] = useState<StyleItem[]>([]);
|
||||
const [name, setName] = useState("");
|
||||
const [from, setFrom] = useState("osm-bright");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const reload = () =>
|
||||
api
|
||||
.styles()
|
||||
.then(setItems)
|
||||
.catch((err: unknown) => setError(err instanceof Error ? err.message : "Не удалось загрузить стили"));
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
}, []);
|
||||
|
||||
const presets = items.filter((item) => item.isPreset);
|
||||
const cloneFrom = presets.length > 0 ? from : "osm-bright";
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
eyebrow="Стили"
|
||||
title="Карта"
|
||||
description="Клонируйте пресет и откройте визуальный редактор: слои, карта и свойства."
|
||||
/>
|
||||
<Section title="Клонировать пресет">
|
||||
<div className="form-row">
|
||||
<AppTextField id="style-name" label="Имя копии" value={name} onChange={(e) => setName(e.target.value)} placeholder="my-bright" />
|
||||
<AppSelect
|
||||
id="style-from"
|
||||
label="Пресет"
|
||||
value={cloneFrom}
|
||||
onChange={(e) => setFrom(e.target.value)}
|
||||
>
|
||||
{(presets.length > 0 ? presets : [{ name: "osm-bright" }]).map((item) => (
|
||||
<option key={item.name} value={item.name}>
|
||||
{item.name}
|
||||
</option>
|
||||
))}
|
||||
</AppSelect>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
void api
|
||||
.cloneStyle(name, cloneFrom)
|
||||
.then((created) => navigate(`/styles/${created.name}`))
|
||||
.catch((err: unknown) => setError(err instanceof Error ? err.message : "Не удалось клонировать"));
|
||||
}}
|
||||
>
|
||||
Клонировать
|
||||
</Button>
|
||||
</div>
|
||||
<Flash error={error} />
|
||||
</Section>
|
||||
<Section title="Список">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Имя</th>
|
||||
<th>Источник</th>
|
||||
<th>Тип</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) => (
|
||||
<tr key={item.name}>
|
||||
<td>
|
||||
<Link to={`/styles/${item.name}`}>{item.name}</Link>
|
||||
</td>
|
||||
<td>{item.createdFrom ?? "—"}</td>
|
||||
<td>{item.isPreset ? "пресет" : "мой"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
.app-shell--workspace .app-shell__main {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.style-workspace {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto 1fr;
|
||||
height: calc(100vh - 44px);
|
||||
min-height: 0;
|
||||
background: var(--grouped-background);
|
||||
}
|
||||
|
||||
.style-workspace__bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
min-height: 52px;
|
||||
padding: 0 var(--gutter);
|
||||
border-bottom: var(--hairline) solid var(--separator);
|
||||
background: var(--grouped-surface);
|
||||
}
|
||||
|
||||
.style-workspace__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.style-workspace__title h1 {
|
||||
margin: 0;
|
||||
font-size: var(--text-title3);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.style-workspace__back {
|
||||
color: var(--accent);
|
||||
font-size: var(--text-footnote);
|
||||
}
|
||||
|
||||
.style-workspace__badge {
|
||||
font-size: var(--text-caption1);
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-capsule);
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.style-workspace__badge.is-preset {
|
||||
background: var(--system-gray5);
|
||||
color: var(--secondary-label);
|
||||
}
|
||||
|
||||
.style-workspace__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: end;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.style-workspace__source {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
font-size: var(--text-caption1);
|
||||
color: var(--secondary-label);
|
||||
}
|
||||
|
||||
.style-workspace__source select {
|
||||
min-height: 34px;
|
||||
min-width: 14rem;
|
||||
border-radius: var(--radius-md);
|
||||
border: var(--hairline) solid var(--separator);
|
||||
background: var(--grouped-background);
|
||||
color: var(--label);
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.style-workspace__flash,
|
||||
.style-workspace__note {
|
||||
margin: 0;
|
||||
padding: var(--space-2) var(--gutter);
|
||||
font-size: var(--text-footnote);
|
||||
color: var(--secondary-label);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.style-workspace__body {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 300px) minmax(0, 1fr) minmax(240px, 300px);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.style-workspace__map {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
background: #111;
|
||||
}
|
||||
|
||||
.live-map {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.style-workspace__empty {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
height: 100%;
|
||||
padding: var(--gutter);
|
||||
color: var(--secondary-label);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.style-pane {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
min-height: 0;
|
||||
background: var(--grouped-surface);
|
||||
border-inline: var(--hairline) solid var(--separator);
|
||||
}
|
||||
|
||||
.style-pane__head {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-bottom: var(--hairline) solid var(--separator);
|
||||
}
|
||||
|
||||
.style-pane__kicker {
|
||||
margin: 0 0 2px;
|
||||
font-size: var(--text-caption1);
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--secondary-label);
|
||||
}
|
||||
|
||||
.style-pane__head strong {
|
||||
display: block;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.style-pane__meta,
|
||||
.style-pane__hint {
|
||||
display: block;
|
||||
margin-top: var(--space-2);
|
||||
color: var(--secondary-label);
|
||||
font-size: var(--text-footnote);
|
||||
}
|
||||
|
||||
.style-pane__search {
|
||||
width: 100%;
|
||||
margin-top: var(--space-2);
|
||||
min-height: 34px;
|
||||
border: var(--hairline) solid var(--separator);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0 10px;
|
||||
background: var(--grouped-background);
|
||||
color: var(--label);
|
||||
}
|
||||
|
||||
.layer-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: var(--space-2);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.layer-list li {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.layer-row {
|
||||
display: grid;
|
||||
grid-template-columns: 4.6rem 1fr;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
width: 100%;
|
||||
min-height: 36px;
|
||||
padding: 4px 8px;
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.layer-row:hover,
|
||||
.layer-row.is-active {
|
||||
background: var(--system-gray5);
|
||||
}
|
||||
|
||||
.layer-row.is-hidden .layer-row__id {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.layer-row__type {
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--secondary-label);
|
||||
}
|
||||
|
||||
.layer-row__id {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: var(--text-footnote);
|
||||
}
|
||||
|
||||
.layer-row__eye {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--secondary-label);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.prop-list {
|
||||
overflow: auto;
|
||||
padding: var(--space-3) var(--space-4) var(--space-5);
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.prop {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
font-size: var(--text-footnote);
|
||||
color: var(--secondary-label);
|
||||
}
|
||||
|
||||
.prop__color,
|
||||
.prop__range,
|
||||
.prop__complex {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.prop__color input[type="color"] {
|
||||
width: 40px;
|
||||
height: 34px;
|
||||
padding: 0;
|
||||
border: var(--hairline) solid var(--separator);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.prop input[type="text"],
|
||||
.prop input[type="number"] {
|
||||
width: 100%;
|
||||
min-height: 34px;
|
||||
border: var(--hairline) solid var(--separator);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0 8px;
|
||||
background: var(--grouped-background);
|
||||
color: var(--label);
|
||||
}
|
||||
|
||||
.prop input[type="range"] {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.prop__toggle {
|
||||
min-height: 34px;
|
||||
border: 0;
|
||||
border-radius: var(--radius-capsule);
|
||||
padding: 0 12px;
|
||||
background: var(--system-gray5);
|
||||
color: var(--label);
|
||||
}
|
||||
|
||||
.prop__toggle.is-on {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.prop__complex em {
|
||||
font-style: normal;
|
||||
color: var(--system-orange);
|
||||
font-size: var(--text-caption1);
|
||||
}
|
||||
|
||||
.prop__complex button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.style-workspace {
|
||||
height: auto;
|
||||
min-height: calc(100vh - 44px);
|
||||
}
|
||||
|
||||
.style-workspace__body {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 48vh auto auto;
|
||||
}
|
||||
|
||||
.style-workspace__map {
|
||||
min-height: 48vh;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
export type LayerField =
|
||||
| { kind: "color"; property: string; label: string }
|
||||
| { kind: "number"; property: string; label: string; min: number; max: number; step: number }
|
||||
| { kind: "visibility" };
|
||||
|
||||
export function fieldsForLayer(type: string): LayerField[] {
|
||||
switch (type) {
|
||||
case "background":
|
||||
return [
|
||||
{ kind: "color", property: "background-color", label: "Цвет фона" },
|
||||
{ kind: "number", property: "background-opacity", label: "Прозрачность", min: 0, max: 1, step: 0.05 }
|
||||
];
|
||||
case "fill":
|
||||
return [
|
||||
{ kind: "color", property: "fill-color", label: "Заливка" },
|
||||
{ kind: "color", property: "fill-outline-color", label: "Обводка" },
|
||||
{ kind: "number", property: "fill-opacity", label: "Прозрачность", min: 0, max: 1, step: 0.05 },
|
||||
{ kind: "visibility" }
|
||||
];
|
||||
case "line":
|
||||
return [
|
||||
{ kind: "color", property: "line-color", label: "Цвет линии" },
|
||||
{ kind: "number", property: "line-width", label: "Толщина", min: 0, max: 24, step: 0.2 },
|
||||
{ kind: "number", property: "line-opacity", label: "Прозрачность", min: 0, max: 1, step: 0.05 },
|
||||
{ kind: "visibility" }
|
||||
];
|
||||
case "circle":
|
||||
return [
|
||||
{ kind: "color", property: "circle-color", label: "Цвет" },
|
||||
{ kind: "number", property: "circle-radius", label: "Радиус", min: 0, max: 24, step: 0.2 },
|
||||
{ kind: "number", property: "circle-opacity", label: "Прозрачность", min: 0, max: 1, step: 0.05 },
|
||||
{ kind: "color", property: "circle-stroke-color", label: "Обводка" },
|
||||
{ kind: "number", property: "circle-stroke-width", label: "Толщина обводки", min: 0, max: 8, step: 0.2 },
|
||||
{ kind: "visibility" }
|
||||
];
|
||||
case "symbol":
|
||||
return [
|
||||
{ kind: "color", property: "text-color", label: "Цвет текста" },
|
||||
{ kind: "color", property: "text-halo-color", label: "Обводка текста" },
|
||||
{ kind: "number", property: "text-halo-width", label: "Толщина обводки", min: 0, max: 8, step: 0.1 },
|
||||
{ kind: "number", property: "text-size", label: "Размер текста", min: 8, max: 32, step: 1 },
|
||||
{ kind: "visibility" }
|
||||
];
|
||||
case "fill-extrusion":
|
||||
return [
|
||||
{ kind: "color", property: "fill-extrusion-color", label: "Цвет" },
|
||||
{ kind: "number", property: "fill-extrusion-opacity", label: "Прозрачность", min: 0, max: 1, step: 0.05 },
|
||||
{ kind: "number", property: "fill-extrusion-height", label: "Высота", min: 0, max: 200, step: 1 },
|
||||
{ kind: "visibility" }
|
||||
];
|
||||
default:
|
||||
return [{ kind: "visibility" }];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
export type StyleLayer = {
|
||||
id: string;
|
||||
type: string;
|
||||
source?: string;
|
||||
"source-layer"?: string;
|
||||
minzoom?: number;
|
||||
maxzoom?: number;
|
||||
filter?: unknown;
|
||||
layout?: Record<string, unknown>;
|
||||
paint?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type MapStyle = {
|
||||
version: number;
|
||||
name?: string;
|
||||
sources?: Record<string, unknown>;
|
||||
glyphs?: string;
|
||||
sprite?: string;
|
||||
layers?: StyleLayer[];
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type StyleEdit = {
|
||||
layerId: string;
|
||||
target: "paint" | "layout";
|
||||
property: string;
|
||||
value: unknown;
|
||||
};
|
||||
|
||||
export function isMapStyle(value: unknown): value is MapStyle {
|
||||
return Boolean(value && typeof value === "object" && "version" in value);
|
||||
}
|
||||
|
||||
export function tilesTemplate(slug: string, source: string, token: string): string {
|
||||
return `/u/${encodeURIComponent(slug)}/tiles/${encodeURIComponent(source)}/{z}/{x}/{y}.pbf?v=3&token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
|
||||
export function bindStyleForPreview(style: MapStyle, tilesUrl: string): MapStyle {
|
||||
const next = structuredClone(style);
|
||||
const sources = { ...(next.sources ?? {}) };
|
||||
for (const key of Object.keys(sources)) {
|
||||
const source = sources[key];
|
||||
if (!source || typeof source !== "object") {
|
||||
continue;
|
||||
}
|
||||
const vector = source as Record<string, unknown>;
|
||||
if (vector.type !== "vector") {
|
||||
continue;
|
||||
}
|
||||
vector.tiles = [tilesUrl];
|
||||
vector.scheme = "xyz";
|
||||
delete vector.url;
|
||||
delete vector.bounds;
|
||||
sources[key] = vector;
|
||||
}
|
||||
next.sources = sources;
|
||||
if (!next.glyphs) {
|
||||
next.glyphs = "https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf";
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function updateLayer(style: MapStyle, edit: StyleEdit): MapStyle {
|
||||
const next = structuredClone(style);
|
||||
const layer = next.layers?.find((item) => item.id === edit.layerId);
|
||||
if (!layer) {
|
||||
return style;
|
||||
}
|
||||
const bag = { ...(layer[edit.target] ?? {}) };
|
||||
if (edit.value === undefined) {
|
||||
delete bag[edit.property];
|
||||
} else {
|
||||
bag[edit.property] = edit.value;
|
||||
}
|
||||
layer[edit.target] = bag;
|
||||
return next;
|
||||
}
|
||||
|
||||
export function asColor(value: unknown): string | null {
|
||||
return typeof value === "string" && (/^#([0-9a-f]{3,8})$/i.test(value) || /^(rgb|hsl)a?\(/i.test(value))
|
||||
? value
|
||||
: null;
|
||||
}
|
||||
|
||||
export function asNumber(value: unknown): number | null {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
export function isExpression(value: unknown): boolean {
|
||||
return Array.isArray(value) || (Boolean(value) && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
|
||||
export function toColorInput(value: string): string {
|
||||
if (/^#[0-9a-f]{6}$/i.test(value)) {
|
||||
return value;
|
||||
}
|
||||
if (/^#[0-9a-f]{3}$/i.test(value)) {
|
||||
const [, a, b, c] = value;
|
||||
return `#${a}${a}${b}${b}${c}${c}`;
|
||||
}
|
||||
return "#888888";
|
||||
}
|
||||
|
||||
export const LAYER_TYPE_LABEL: Record<string, string> = {
|
||||
background: "фон",
|
||||
fill: "заливка",
|
||||
line: "линия",
|
||||
symbol: "подпись",
|
||||
circle: "точка",
|
||||
"fill-extrusion": "объём",
|
||||
heatmap: "тепло",
|
||||
hillshade: "рельеф",
|
||||
raster: "растр"
|
||||
};
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, getPreviewToken, setPreviewToken, type CreatedToken, type TokenItem } from "../../api";
|
||||
import { Button, Flash, PageHeader, Section } from "../../components/ui";
|
||||
import { AppTextField } from "../../ui";
|
||||
|
||||
export function TokensPage() {
|
||||
const [items, setItems] = useState<TokenItem[]>([]);
|
||||
const [name, setName] = useState("MapLibre");
|
||||
const [created, setCreated] = useState<CreatedToken | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const preview = getPreviewToken();
|
||||
|
||||
const reload = () =>
|
||||
api
|
||||
.tokens()
|
||||
.then(setItems)
|
||||
.catch((err: unknown) => setError(err instanceof Error ? err.message : "Не удалось загрузить токены"));
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
eyebrow="Доступ"
|
||||
title="Токены"
|
||||
description="Ключ показывается один раз. Им открывают персональные /u/{slug}/tiles и стили."
|
||||
/>
|
||||
<Section title="Создать">
|
||||
<div className="form-row">
|
||||
<AppTextField id="token-name" label="Имя" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
void api
|
||||
.createToken(name)
|
||||
.then((token) => {
|
||||
setCreated(token);
|
||||
setPreviewToken(token.token);
|
||||
void reload();
|
||||
})
|
||||
.catch((err: unknown) => setError(err instanceof Error ? err.message : "Не удалось создать токен"));
|
||||
}}
|
||||
>
|
||||
Создать
|
||||
</Button>
|
||||
</div>
|
||||
{created ? (
|
||||
<pre className="code-block">{`Новый ключ (показывается один раз):\n${created.token}\n\nОн сохранён как превью-токен этой сессии.`}</pre>
|
||||
) : null}
|
||||
{preview && !created ? <pre className="code-block">{`Превью этой сессии: ${preview.slice(0, 11)}…`}</pre> : null}
|
||||
<Flash error={error} />
|
||||
</Section>
|
||||
<Section title="Список">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Имя</th>
|
||||
<th>Префикс</th>
|
||||
<th>Создан</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) => (
|
||||
<tr key={item.id}>
|
||||
<td>{item.name}</td>
|
||||
<td>{item.prefix}…</td>
|
||||
<td>{new Date(item.createdAt).toLocaleString("ru-RU")}</td>
|
||||
<td>
|
||||
{item.revokedAt ? (
|
||||
"отозван"
|
||||
) : (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
void api.revokeToken(item.id).then(() => void reload());
|
||||
}}
|
||||
>
|
||||
Отозвать
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { api, type UsageDay } from "../../api";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Flash, MetricGrid, PageHeader, Section } from "../../components/ui";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
|
||||
function iso(date: Date): string {
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function formatBytes(value: number): string {
|
||||
if (value < 1024) {
|
||||
return `${value} B`;
|
||||
}
|
||||
if (value < 1024 * 1024) {
|
||||
return `${(value / 1024).toFixed(1)} KB`;
|
||||
}
|
||||
return `${(value / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export function DashboardPage() {
|
||||
const { user } = useAuth();
|
||||
const [days, setDays] = useState<UsageDay[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const to = new Date();
|
||||
const from = new Date();
|
||||
from.setUTCDate(from.getUTCDate() - 6);
|
||||
void api
|
||||
.usage(iso(from), iso(to))
|
||||
.then(setDays)
|
||||
.catch((err: unknown) => setError(err instanceof Error ? err.message : "Не удалось загрузить данные"));
|
||||
}, []);
|
||||
|
||||
const today = iso(new Date());
|
||||
const todayRow = days.find((d) => d.date === today);
|
||||
const week = useMemo(
|
||||
() =>
|
||||
days.reduce(
|
||||
(acc, day) => ({
|
||||
tileRequests: acc.tileRequests + day.tileRequests,
|
||||
styleRequests: acc.styleRequests + day.styleRequests,
|
||||
bytes: acc.bytes + day.bytes
|
||||
}),
|
||||
{ tileRequests: 0, styleRequests: 0, bytes: 0 }
|
||||
),
|
||||
[days]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
eyebrow="Кабинет"
|
||||
title="Использование"
|
||||
description={`Считаются только запросы на персональных URL /u/${user?.slug}/…`}
|
||||
/>
|
||||
<Flash error={error} />
|
||||
<MetricGrid
|
||||
items={[
|
||||
{ label: "Тайлы сегодня", value: todayRow?.tileRequests ?? 0 },
|
||||
{ label: "Стили сегодня", value: todayRow?.styleRequests ?? 0 },
|
||||
{ label: "Тайлы за 7 дней", value: week.tileRequests },
|
||||
{ label: "Трафик за 7 дней", value: formatBytes(week.bytes) }
|
||||
]}
|
||||
/>
|
||||
<Section title="Подключение" description="Публичные стили и тайлы закрыты — только /u/{slug} и токен.">
|
||||
<p className="section__desc" style={{ padding: 0 }}>
|
||||
Примеры кода, HTML и живая карта — в разделе <Link to="/guides">Инструкции</Link>.
|
||||
</p>
|
||||
</Section>
|
||||
<Section title="По дням">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Дата</th>
|
||||
<th>Тайлы</th>
|
||||
<th>Стили</th>
|
||||
<th>Байты</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{days.map((day) => (
|
||||
<tr key={day.date}>
|
||||
<td>{day.date}</td>
|
||||
<td>{day.tileRequests}</td>
|
||||
<td>{day.styleRequests}</td>
|
||||
<td>{formatBytes(day.bytes)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { AppButton, AppText } from "../ui";
|
||||
import {
|
||||
readCookieChoice,
|
||||
resetCookieChoice,
|
||||
subscribeCookieChoice,
|
||||
writeCookieChoice,
|
||||
} from "./cookieConsent";
|
||||
import { LEGAL_PATHS } from "./operator";
|
||||
|
||||
export function CookieBanner() {
|
||||
const [choice, setChoice] = useState(() => readCookieChoice());
|
||||
|
||||
useEffect(() => subscribeCookieChoice(() => setChoice(readCookieChoice())), []);
|
||||
|
||||
if (choice) return null;
|
||||
|
||||
return (
|
||||
<div className="cookie-banner" role="dialog" aria-label="Использование cookie">
|
||||
<AppText variant="footnote">
|
||||
Мы используем технические cookie, чтобы вы оставались в кабинете. Аналитические cookie
|
||||
(Яндекс.Метрика) включаются только с вашего согласия.{" "}
|
||||
<Link className="legal-link" to={LEGAL_PATHS.cookies}>
|
||||
Подробнее
|
||||
</Link>
|
||||
</AppText>
|
||||
<div className="cookie-banner__actions">
|
||||
<AppButton
|
||||
size="small"
|
||||
buttonStyle="gray"
|
||||
expanded={false}
|
||||
onClick={() => writeCookieChoice("necessary")}
|
||||
>
|
||||
Только необходимые
|
||||
</AppButton>
|
||||
<AppButton size="small" expanded={false} onClick={() => writeCookieChoice("all")}>
|
||||
Принять все
|
||||
</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CookieSettings() {
|
||||
return (
|
||||
<div className="cookie-banner__actions">
|
||||
<AppButton
|
||||
size="small"
|
||||
buttonStyle="gray"
|
||||
expanded={false}
|
||||
onClick={() => {
|
||||
resetCookieChoice();
|
||||
}}
|
||||
>
|
||||
Изменить выбор cookie
|
||||
</AppButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { LEGAL_PATHS } from "./operator";
|
||||
|
||||
type Props = {
|
||||
offer: boolean;
|
||||
consent: boolean;
|
||||
onOffer: (value: boolean) => void;
|
||||
onConsent: (value: boolean) => void;
|
||||
};
|
||||
|
||||
export function LegalCheckboxes({ offer, consent, onOffer, onConsent }: Props) {
|
||||
return (
|
||||
<div className="legal-checks">
|
||||
<label className="legal-check">
|
||||
<input type="checkbox" checked={offer} onChange={(e) => onOffer(e.target.checked)} />
|
||||
<span>
|
||||
Я принимаю условия{" "}
|
||||
<Link className="legal-link" to={LEGAL_PATHS.offer} target="_blank" rel="noreferrer">
|
||||
Пользовательского соглашения
|
||||
</Link>{" "}
|
||||
(публичной оферты).
|
||||
</span>
|
||||
</label>
|
||||
<label className="legal-check">
|
||||
<input type="checkbox" checked={consent} onChange={(e) => onConsent(e.target.checked)} />
|
||||
<span>
|
||||
Я даю согласие на обработку моих персональных данных (имя, логин Яндекс ID, стили карт,
|
||||
токены доступа и статистика запросов), полученных от сервиса Яндекс и введённых мной, в
|
||||
целях предоставления доступа к Сервису. Согласие действует до его отзыва.{" "}
|
||||
<Link className="legal-link" to={LEGAL_PATHS.consent} target="_blank" rel="noreferrer">
|
||||
Текст согласия
|
||||
</Link>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Link, Navigate, useParams } from "react-router-dom";
|
||||
import { AppText } from "../ui";
|
||||
import { CookieSettings } from "./CookieBanner";
|
||||
import { legalDocumentBySlug } from "./documents";
|
||||
import { SiteFooter } from "./SiteFooter";
|
||||
|
||||
export function LegalPage() {
|
||||
const { slug } = useParams();
|
||||
const doc = legalDocumentBySlug(slug);
|
||||
|
||||
if (!doc) {
|
||||
return <Navigate to="/legal/offer" replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="legal-page">
|
||||
<article className="legal-page__inner">
|
||||
<Link className="legal-page__back" to="/">
|
||||
← Назад
|
||||
</Link>
|
||||
<header className="legal-page__meta">
|
||||
<AppText variant="largeTitle" as="h1">
|
||||
{doc.title}
|
||||
</AppText>
|
||||
<AppText variant="subhead">{doc.lead}</AppText>
|
||||
</header>
|
||||
{doc.sections.map((section) => (
|
||||
<section key={section.heading} className="legal-section">
|
||||
<h2>{section.heading}</h2>
|
||||
{section.blocks.map((block, index) =>
|
||||
block.type === "ul" ? (
|
||||
<ul key={index}>
|
||||
{block.items.map((item) => (
|
||||
<li key={item}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p key={index}>{block.text}</p>
|
||||
),
|
||||
)}
|
||||
</section>
|
||||
))}
|
||||
{doc.slug === "cookies" ? <CookieSettings /> : null}
|
||||
</article>
|
||||
<SiteFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { LEGAL_PATHS, OPERATOR } from "./operator";
|
||||
|
||||
const LINKS = [
|
||||
{ to: LEGAL_PATHS.offer, label: "Оферта" },
|
||||
{ to: LEGAL_PATHS.privacy, label: "Конфиденциальность" },
|
||||
{ to: LEGAL_PATHS.consent, label: "Согласие" },
|
||||
{ to: LEGAL_PATHS.cookies, label: "Cookie" },
|
||||
] as const;
|
||||
|
||||
export function SiteFooter() {
|
||||
return (
|
||||
<footer className="site-footer">
|
||||
<div className="site-footer__inner">
|
||||
<nav className="site-footer__links" aria-label="Правовая информация">
|
||||
{LINKS.map((item) => (
|
||||
<Link key={item.to} to={item.to}>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
<p className="site-footer__copy">
|
||||
{OPERATOR.shortName} · ИНН {OPERATOR.inn} · {OPERATOR.email}
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
export const COOKIE_CONSENT_KEY = "ts_cookie_consent";
|
||||
export type CookieChoice = "necessary" | "all";
|
||||
|
||||
const EVENT = "ts-cookie-consent";
|
||||
|
||||
export function readCookieChoice(): CookieChoice | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(COOKIE_CONSENT_KEY);
|
||||
if (raw === "necessary" || raw === "all") {
|
||||
return raw;
|
||||
}
|
||||
} catch {
|
||||
/* private mode */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function writeCookieChoice(choice: CookieChoice): void {
|
||||
try {
|
||||
localStorage.setItem(COOKIE_CONSENT_KEY, choice);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
window.dispatchEvent(new Event(EVENT));
|
||||
}
|
||||
|
||||
export function resetCookieChoice(): void {
|
||||
try {
|
||||
localStorage.removeItem(COOKIE_CONSENT_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
window.dispatchEvent(new Event(EVENT));
|
||||
}
|
||||
|
||||
export function subscribeCookieChoice(listener: () => void): () => void {
|
||||
window.addEventListener(EVENT, listener);
|
||||
window.addEventListener("storage", listener);
|
||||
return () => {
|
||||
window.removeEventListener(EVENT, listener);
|
||||
window.removeEventListener("storage", listener);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
import { LEGAL_PATHS, OPERATOR } from "./operator";
|
||||
|
||||
export type LegalBlock = { type: "p"; text: string } | { type: "ul"; items: string[] };
|
||||
|
||||
export type LegalSection = {
|
||||
heading: string;
|
||||
blocks: LegalBlock[];
|
||||
};
|
||||
|
||||
export type LegalDocument = {
|
||||
slug: keyof typeof LEGAL_PATHS;
|
||||
title: string;
|
||||
lead: string;
|
||||
sections: LegalSection[];
|
||||
};
|
||||
|
||||
const headerLines = [
|
||||
OPERATOR.name,
|
||||
`ИНН: ${OPERATOR.inn}`,
|
||||
`ОГРНИП: ${OPERATOR.ogrnip}`,
|
||||
`Адрес: ${OPERATOR.address}`,
|
||||
`Email для обращений: ${OPERATOR.email}`,
|
||||
];
|
||||
|
||||
export const legalDocuments: LegalDocument[] = [
|
||||
{
|
||||
slug: "offer",
|
||||
title: "Пользовательское соглашение (публичная оферта)",
|
||||
lead: `Настоящий документ является официальным предложением (${OPERATOR.shortName}) заключить договор на изложенных ниже условиях. Дата публикации: ${OPERATOR.updated}.`,
|
||||
sections: [
|
||||
{
|
||||
heading: "Реквизиты исполнителя",
|
||||
blocks: [{ type: "ul", items: [...headerLines] }],
|
||||
},
|
||||
{
|
||||
heading: "1. Термины",
|
||||
blocks: [
|
||||
{
|
||||
type: "ul",
|
||||
items: [
|
||||
"Пользователь — дееспособное физическое лицо, принявшее условия настоящей Оферты и использующее Сервис.",
|
||||
`Сервис — веб-кабинет и программные интерфейсы «${OPERATOR.serviceName}» по адресу ${OPERATOR.site}, предназначенные для выдачи векторных тайлов OpenStreetMap, стилей MapLibre и учёта запросов.`,
|
||||
"Оферта — настоящий документ.",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "2. Предмет",
|
||||
blocks: [
|
||||
{
|
||||
type: "p",
|
||||
text: "Исполнитель предоставляет Пользователю доступ к Сервису: личный кабинет, персональные токены, стили карты и URL выдачи тайлов. Исходные выгрузки OSM являются общими и не создаются Пользователем.",
|
||||
},
|
||||
{
|
||||
type: "p",
|
||||
text: "Данные OpenStreetMap предоставляются на условиях ODbL. Исполнитель не является правообладателем картографических данных OSM.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "3. Акцепт",
|
||||
blocks: [
|
||||
{
|
||||
type: "p",
|
||||
text: "Вход в Сервис через Яндекс ID и начало использования функционала являются акцептом настоящей Оферты.",
|
||||
},
|
||||
{
|
||||
type: "p",
|
||||
text: "Используя Сервис, Пользователь подтверждает, что ознакомился с Политикой конфиденциальности и дал согласие на обработку персональных данных в необходимом объёме.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "4. Ответственность",
|
||||
blocks: [
|
||||
{
|
||||
type: "ul",
|
||||
items: [
|
||||
"Сервис предоставляется «как есть». Исполнитель не гарантирует бесперебойную работу и полноту покрытия карты.",
|
||||
"Исполнитель не отвечает за действия третьих лиц, получивших токен Пользователя, и за последствия разглашения токена или доступа к Яндекс ID.",
|
||||
"Пользователь обязан соблюдать условия OpenStreetMap и не использовать Сервис для противоправных целей.",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "5. Прочие условия",
|
||||
blocks: [
|
||||
{
|
||||
type: "p",
|
||||
text: "Исполнитель вправе изменять Оферту, публикуя новую редакцию на этой странице. Продолжение использования Сервиса после публикации означает принятие новой редакции, если иное не требуется законом.",
|
||||
},
|
||||
{
|
||||
type: "p",
|
||||
text: `Споры решаются путём переговоров, а при недостижении согласия — в порядке, установленном законодательством РФ. Контакт: ${OPERATOR.email}.`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "privacy",
|
||||
title: "Политика конфиденциальности",
|
||||
lead: `Настоящая Политика описывает, какие персональные данные обрабатывает ${OPERATOR.shortName} при работе Сервиса «${OPERATOR.serviceName}». Дата публикации: ${OPERATOR.updated}.`,
|
||||
sections: [
|
||||
{
|
||||
heading: "Реквизиты оператора",
|
||||
blocks: [{ type: "ul", items: [...headerLines] }],
|
||||
},
|
||||
{
|
||||
heading: "1. Оператор",
|
||||
blocks: [
|
||||
{
|
||||
type: "p",
|
||||
text: `${OPERATOR.name} (ИНН ${OPERATOR.inn}, ОГРНИП ${OPERATOR.ogrnip}), адрес: ${OPERATOR.address}, email: ${OPERATOR.email}, является оператором персональных данных Пользователей Сервиса.`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "2. Какие данные собираются",
|
||||
blocks: [
|
||||
{
|
||||
type: "ul",
|
||||
items: [
|
||||
"От Яндекса (OAuth / Яндекс ID): идентификатор, логин, отображаемое имя.",
|
||||
"Введённые Пользователем: названия и JSON стилей карты, имена токенов.",
|
||||
"Автоматически: cookie сессии, IP-адрес, технические журналы и агрегаты запросов тайлов/стилей (число запросов, объём).",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "3. Цели обработки",
|
||||
blocks: [
|
||||
{
|
||||
type: "ul",
|
||||
items: [
|
||||
"Исполнение договора: вход в кабинет, выдача тайлов и стилей, учёт использования.",
|
||||
"Связь с Пользователем: ответы на обращения в поддержку.",
|
||||
"Улучшение работы Сервиса: обезличенная аналитика сбоев (при наличии согласия на аналитические cookie).",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "4. Передача третьим лицам",
|
||||
blocks: [
|
||||
{
|
||||
type: "p",
|
||||
text: "Яндекс — для авторизации через Яндекс ID; Исполнитель получает от Яндекса указанные выше сведения профиля. Данные банковских карт Исполнитель не получает и не хранит.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "5. Права Пользователя и сроки",
|
||||
blocks: [
|
||||
{
|
||||
type: "p",
|
||||
text: `Пользователь вправе запросить доступ к своим данным, их уточнение, ограничение обработки, удаление аккаунта и отзыв согласия, направив письмо на ${OPERATOR.email}.`,
|
||||
},
|
||||
{
|
||||
type: "p",
|
||||
text: "После удаления аккаунта персональные данные хранятся не дольше 3 лет — в объёме, необходимом для исполнения требований законодательства, затем уничтожаются либо обезличиваются.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "6. Cookie",
|
||||
blocks: [
|
||||
{
|
||||
type: "p",
|
||||
text: "Сервис использует технические cookie для входа и сохранения сессии, а также — только после согласия — аналитические cookie (Яндекс.Метрика). Подробности: Политика использования cookie.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "consent",
|
||||
title: "Согласие на обработку персональных данных",
|
||||
lead: "Текст согласия, которое Пользователь даёт, отмечая соответствующий чек-бокс при входе в Сервис.",
|
||||
sections: [
|
||||
{
|
||||
heading: "Реквизиты оператора",
|
||||
blocks: [{ type: "ul", items: [...headerLines] }],
|
||||
},
|
||||
{
|
||||
heading: "Текст согласия",
|
||||
blocks: [
|
||||
{
|
||||
type: "p",
|
||||
text: `Я, Пользователь Сервиса «${OPERATOR.serviceName}», даю согласие ${OPERATOR.shortName} (ИНН ${OPERATOR.inn}) на обработку моих персональных данных: логин и отображаемое имя Яндекс ID, стили карт, токены доступа и статистика запросов.`,
|
||||
},
|
||||
{
|
||||
type: "p",
|
||||
text: "Цели: предоставление доступа к Сервису, связь со мной, улучшение Сервиса.",
|
||||
},
|
||||
{
|
||||
type: "p",
|
||||
text: "Способы обработки: сбор, запись, систематизация, хранение, уточнение, использование, передача (Яндекс — для авторизации), удаление — в том числе автоматизированно.",
|
||||
},
|
||||
{
|
||||
type: "p",
|
||||
text: `Согласие действует до его отзыва. Отзыв направляется по email ${OPERATOR.email}. Отзыв не влияет на законность обработки, осуществлённой до его получения, и может сделать невозможным дальнейшее использование Сервиса.`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "cookies",
|
||||
title: "Политика использования cookie",
|
||||
lead: `Документ описывает, какие cookie использует Сервис «${OPERATOR.serviceName}» и как отказаться от аналитических. Дата публикации: ${OPERATOR.updated}.`,
|
||||
sections: [
|
||||
{
|
||||
heading: "Реквизиты оператора",
|
||||
blocks: [{ type: "ul", items: [...headerLines] }],
|
||||
},
|
||||
{
|
||||
heading: "1. Что такое cookie",
|
||||
blocks: [
|
||||
{
|
||||
type: "p",
|
||||
text: "Cookie — небольшие файлы, которые сайт сохраняет в браузере. Они помогают запомнить вход и понять, как пользуются Сервисом.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "2. Какие cookie мы используем",
|
||||
blocks: [
|
||||
{
|
||||
type: "ul",
|
||||
items: [
|
||||
"Технические — обязательны для входа и работы кабинета (сессия ts_session, выбор cookie). Без них авторизация невозможна.",
|
||||
"Аналитические — Яндекс.Метрика: посещения страниц, источник перехода, тип устройства. Ставятся только после согласия «Принять все».",
|
||||
"Рекламные cookie сейчас не используются.",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "3. Как отказаться",
|
||||
blocks: [
|
||||
{
|
||||
type: "p",
|
||||
text: "При первом заходе показывается баннер. Можно выбрать «Только необходимые» — аналитические cookie не устанавливаются. Выбор можно изменить кнопкой ниже или очистив данные сайта в браузере.",
|
||||
},
|
||||
{
|
||||
type: "p",
|
||||
text: `Вопросы: ${OPERATOR.email}.`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function legalDocumentBySlug(slug: string | undefined): LegalDocument | undefined {
|
||||
return legalDocuments.find((doc) => doc.slug === slug);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
.legal-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--grouped-background);
|
||||
}
|
||||
|
||||
.legal-page__inner {
|
||||
width: min(720px, 100%);
|
||||
margin: 0 auto;
|
||||
padding: var(--space-6) var(--gutter) var(--space-8);
|
||||
display: grid;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
|
||||
.legal-page__back {
|
||||
color: var(--accent);
|
||||
font-size: var(--text-subhead);
|
||||
font-weight: 600;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.legal-page__meta {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.legal-section {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.legal-section h2 {
|
||||
font-size: var(--text-headline);
|
||||
}
|
||||
|
||||
.legal-section p,
|
||||
.legal-section li {
|
||||
font-size: var(--text-body);
|
||||
line-height: 1.45;
|
||||
color: var(--label);
|
||||
}
|
||||
|
||||
.legal-section ul {
|
||||
margin: 0;
|
||||
padding-left: 1.2em;
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.legal-link {
|
||||
color: var(--accent);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
margin-top: auto;
|
||||
padding: var(--space-4) var(--gutter) calc(var(--space-5) + env(safe-area-inset-bottom, 0px));
|
||||
border-top: var(--hairline) solid var(--separator);
|
||||
background: var(--bar-background);
|
||||
}
|
||||
|
||||
.site-footer__inner {
|
||||
width: min(720px, 100%);
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.site-footer__links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2) var(--space-4);
|
||||
}
|
||||
|
||||
.site-footer__links a {
|
||||
color: var(--secondary-label);
|
||||
font-size: var(--text-footnote);
|
||||
}
|
||||
|
||||
.site-footer__links a:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.site-footer__copy {
|
||||
margin: 0;
|
||||
color: var(--tertiary-label);
|
||||
font-size: var(--text-caption1);
|
||||
}
|
||||
|
||||
.app-shell .site-footer {
|
||||
margin-top: var(--space-4);
|
||||
border-top: 0;
|
||||
background: transparent;
|
||||
padding: var(--space-3) var(--gutter) 0;
|
||||
}
|
||||
|
||||
.auth-panel__foot .site-footer {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.auth-panel__foot .site-footer__inner {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.app-shell .site-footer__inner {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.legal-check {
|
||||
display: grid;
|
||||
grid-template-columns: 22px 1fr;
|
||||
gap: var(--space-2);
|
||||
align-items: start;
|
||||
margin: 0;
|
||||
color: var(--secondary-label);
|
||||
font-size: var(--text-footnote);
|
||||
line-height: 1.4;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.legal-check input {
|
||||
margin-top: 2px;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.legal-checks {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.cookie-banner {
|
||||
position: fixed;
|
||||
left: var(--gutter);
|
||||
right: var(--gutter);
|
||||
bottom: calc(var(--gutter) + env(safe-area-inset-bottom, 0px));
|
||||
z-index: 50;
|
||||
max-width: 560px;
|
||||
margin: 0 auto;
|
||||
padding: var(--space-4);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--grouped-surface);
|
||||
box-shadow: 0 12px 40px rgb(0 0 0 / 18%);
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.cookie-banner__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.btn--yandex.is-disabled,
|
||||
.btn--yandex[aria-disabled="true"] {
|
||||
opacity: 0.45;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.app-shell .site-footer {
|
||||
padding-inline: 0;
|
||||
}
|
||||
|
||||
.app-shell .site-footer__links {
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export const OPERATOR = {
|
||||
name: "Индивидуальный предприниматель Архангельский Владимир Александрович",
|
||||
shortName: "ИП Архангельский В.А.",
|
||||
inn: "772270222393",
|
||||
ogrnip: "323774600438338",
|
||||
address: "105037, г. Москва, ул. 2-я Парковая, д. 16, кв. 8",
|
||||
email: "hohnergold@yandex.ru",
|
||||
serviceName: "Tile Server",
|
||||
site: "https://tile-server.ru",
|
||||
updated: "21 сентября 2026 г.",
|
||||
} as const;
|
||||
|
||||
export const LEGAL_PATHS = {
|
||||
offer: "/legal/offer",
|
||||
privacy: "/legal/privacy",
|
||||
consent: "/legal/consent",
|
||||
cookies: "/legal/cookies",
|
||||
} as const;
|
||||
|
||||
export type LegalSlug = keyof typeof LEGAL_PATHS;
|
||||
@@ -0,0 +1,16 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { App } from "./App";
|
||||
import { AuthProvider } from "./features/auth/AuthContext";
|
||||
import "./styles.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,60 @@
|
||||
@import "./design/index.css";
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--text-subhead);
|
||||
}
|
||||
|
||||
.data-table th,
|
||||
.data-table td {
|
||||
text-align: left;
|
||||
padding: var(--space-3) var(--space-2);
|
||||
border-bottom: 1px solid var(--separator, rgba(60, 60, 67, 0.12));
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
color: var(--label-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.code-block {
|
||||
overflow: auto;
|
||||
margin: 0;
|
||||
padding: var(--space-4);
|
||||
border-radius: 12px;
|
||||
background: var(--fill-secondary, rgba(120, 120, 128, 0.12));
|
||||
font-family: ui-monospace, "Cascadia Mono", Consolas, monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.style-json {
|
||||
width: 100%;
|
||||
min-height: 22rem;
|
||||
resize: vertical;
|
||||
font-family: ui-monospace, "Cascadia Mono", Consolas, monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
padding: var(--space-4);
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--separator, rgba(60, 60, 67, 0.12));
|
||||
background: var(--fill-secondary, rgba(120, 120, 128, 0.08));
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.map-frame {
|
||||
height: 28rem;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--separator, rgba(60, 60, 67, 0.12));
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3);
|
||||
align-items: end;
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
||||
|
||||
export type AppButtonStyle = "filled" | "tinted" | "gray" | "plain" | "destructive";
|
||||
export type AppButtonSize = "large" | "medium" | "small";
|
||||
|
||||
type Props = ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
label?: ReactNode;
|
||||
children?: ReactNode;
|
||||
buttonStyle?: AppButtonStyle;
|
||||
size?: AppButtonSize;
|
||||
expanded?: boolean;
|
||||
loading?: boolean;
|
||||
};
|
||||
|
||||
export function AppButton({
|
||||
label,
|
||||
children,
|
||||
buttonStyle = "filled",
|
||||
size = "large",
|
||||
expanded = true,
|
||||
loading = false,
|
||||
className = "",
|
||||
disabled,
|
||||
type = "button",
|
||||
...rest
|
||||
}: Props) {
|
||||
const classes = [
|
||||
"app-btn",
|
||||
`app-btn--${buttonStyle}`,
|
||||
`app-btn--${size}`,
|
||||
expanded ? "app-btn--expanded" : "",
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<button type={type} className={classes} disabled={disabled || loading} {...rest}>
|
||||
{loading ? <span className="app-spinner" aria-hidden="true" /> : (label ?? children)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { AppButton } from "./AppButton";
|
||||
import { AppText } from "./AppText";
|
||||
|
||||
export function AppSpinner() {
|
||||
return <span className="app-spinner" role="status" aria-label="Загрузка" />;
|
||||
}
|
||||
|
||||
export function AppSkeleton({ width = "100%", height = 12 }: { width?: string | number; height?: number }) {
|
||||
return <span className="app-skeleton" style={{ width, height }} />;
|
||||
}
|
||||
|
||||
export function AppProgress({
|
||||
spent,
|
||||
total,
|
||||
label,
|
||||
}: {
|
||||
spent: number;
|
||||
total: number;
|
||||
label?: string;
|
||||
}) {
|
||||
const pct = total > 0 ? Math.min(100, Math.max(0, (spent / total) * 100)) : 0;
|
||||
const tone = pct >= 100 ? "is-danger" : pct >= 85 ? "is-warn" : "";
|
||||
return (
|
||||
<div className="app-progress">
|
||||
<div
|
||||
className="app-progress__track"
|
||||
role="progressbar"
|
||||
aria-valuenow={Math.round(pct)}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-label={label ?? "Потрачено от бюджета"}
|
||||
>
|
||||
<span className={`app-progress__bar ${tone}`.trim()} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppEmptyView({
|
||||
title = "Пусто",
|
||||
message,
|
||||
action,
|
||||
}: {
|
||||
title?: string;
|
||||
message?: ReactNode;
|
||||
action?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="app-empty">
|
||||
<AppText variant="headline">{title}</AppText>
|
||||
{message ? <AppText variant="subhead">{message}</AppText> : null}
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppErrorView({
|
||||
message,
|
||||
onRetry,
|
||||
}: {
|
||||
message: string;
|
||||
onRetry?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="app-error">
|
||||
<AppText variant="headline">Не удалось загрузить</AppText>
|
||||
<AppText variant="subhead">{message}</AppText>
|
||||
{onRetry ? (
|
||||
<AppButton label="Повторить" buttonStyle="tinted" size="medium" expanded={false} onClick={onRetry} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppChip({
|
||||
label,
|
||||
selected = false,
|
||||
onClick,
|
||||
disabled,
|
||||
}: {
|
||||
label: string;
|
||||
selected?: boolean;
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button type="button" className={`app-chip${selected ? " is-on" : ""}`} onClick={onClick} disabled={disabled}>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppAvatar({ initials }: { initials: string }) {
|
||||
return <span className="app-avatar">{initials}</span>;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { AppSectionHeader } from "./AppText";
|
||||
|
||||
type SectionProps = {
|
||||
header?: string;
|
||||
footer?: ReactNode;
|
||||
children: ReactNode;
|
||||
flush?: boolean;
|
||||
};
|
||||
|
||||
export function AppListSection({ header, footer, children, flush = false }: SectionProps) {
|
||||
return (
|
||||
<section>
|
||||
{header ? <AppSectionHeader>{header}</AppSectionHeader> : null}
|
||||
<div className={`app-list${flush ? " app-list--flush" : ""}`}>{children}</div>
|
||||
{footer ? <p className="app-section-footer">{footer}</p> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
type TileProps = {
|
||||
title: ReactNode;
|
||||
subtitle?: ReactNode;
|
||||
value?: ReactNode;
|
||||
leading?: ReactNode;
|
||||
trailing?: ReactNode;
|
||||
chevron?: boolean;
|
||||
destructive?: boolean;
|
||||
onClick?: () => void;
|
||||
href?: string;
|
||||
};
|
||||
|
||||
export function AppListTile({
|
||||
title,
|
||||
subtitle,
|
||||
value,
|
||||
leading,
|
||||
trailing,
|
||||
chevron = false,
|
||||
destructive = false,
|
||||
onClick,
|
||||
href,
|
||||
}: TileProps) {
|
||||
const interactive = Boolean(onClick || href);
|
||||
const className = `app-tile${destructive ? " app-tile--destructive" : ""}`;
|
||||
const body = (
|
||||
<>
|
||||
{leading}
|
||||
<div className="app-tile__body">
|
||||
<p className="app-tile__title">{title}</p>
|
||||
{subtitle ? <p className="app-tile__subtitle">{subtitle}</p> : null}
|
||||
</div>
|
||||
{trailing ?? (value != null ? <p className="app-tile__value">{value}</p> : null)}
|
||||
{chevron ? <span className="app-tile__chevron" aria-hidden="true">›</span> : null}
|
||||
</>
|
||||
);
|
||||
|
||||
if (href) {
|
||||
return (
|
||||
<a className={className} href={href}>
|
||||
{body}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
if (interactive) {
|
||||
return (
|
||||
<button type="button" className={className} onClick={onClick}>
|
||||
{body}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return <div className={className}>{body}</div>;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
type Option<T extends string> = { value: T; label: string };
|
||||
|
||||
type Props<T extends string> = {
|
||||
value: T;
|
||||
options: Option<T>[];
|
||||
onChange: (value: T) => void;
|
||||
ariaLabel?: string;
|
||||
};
|
||||
|
||||
export function AppSegmented<T extends string>({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
ariaLabel,
|
||||
}: Props<T>) {
|
||||
return (
|
||||
<div className="app-segmented" role="tablist" aria-label={ariaLabel}>
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={option.value === value}
|
||||
className={`app-segmented__item${option.value === value ? " is-on" : ""}`}
|
||||
onClick={() => onChange(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { ElementType, ReactNode } from "react";
|
||||
|
||||
export type AppTextVariant =
|
||||
| "largeTitle"
|
||||
| "title1"
|
||||
| "title2"
|
||||
| "title3"
|
||||
| "headline"
|
||||
| "body"
|
||||
| "callout"
|
||||
| "subhead"
|
||||
| "footnote"
|
||||
| "caption";
|
||||
|
||||
export type AppTextTone = "label" | "secondary" | "tertiary" | "accent" | "danger";
|
||||
|
||||
const DEFAULT_TONE: Record<AppTextVariant, AppTextTone> = {
|
||||
largeTitle: "label",
|
||||
title1: "label",
|
||||
title2: "label",
|
||||
title3: "label",
|
||||
headline: "label",
|
||||
body: "label",
|
||||
callout: "secondary",
|
||||
subhead: "secondary",
|
||||
footnote: "secondary",
|
||||
caption: "tertiary",
|
||||
};
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
variant?: AppTextVariant;
|
||||
tone?: AppTextTone;
|
||||
as?: ElementType;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function AppText({
|
||||
children,
|
||||
variant = "body",
|
||||
tone,
|
||||
as: Tag = "p",
|
||||
className = "",
|
||||
}: Props) {
|
||||
const color = tone ?? DEFAULT_TONE[variant];
|
||||
return (
|
||||
<Tag className={`app-text app-text--${variant} app-text--${color} ${className}`.trim()}>
|
||||
{children}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppSectionHeader({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<span className="app-section-header">
|
||||
{typeof children === "string" ? children.toUpperCase() : children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { InputHTMLAttributes, ReactNode, SelectHTMLAttributes } from "react";
|
||||
import { AppSectionHeader } from "./AppText";
|
||||
|
||||
type FieldProps = {
|
||||
label?: string;
|
||||
hint?: ReactNode;
|
||||
error?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function AppTextField({
|
||||
label,
|
||||
id,
|
||||
hint,
|
||||
error,
|
||||
className = "",
|
||||
...rest
|
||||
}: InputHTMLAttributes<HTMLInputElement> & FieldProps & { id: string }) {
|
||||
return (
|
||||
<div className={`app-field ${error ? "app-field--error" : ""} ${className}`.trim()}>
|
||||
{label ? (
|
||||
<label htmlFor={id}>
|
||||
<AppSectionHeader>{label}</AppSectionHeader>
|
||||
</label>
|
||||
) : null}
|
||||
<input id={id} className="app-field__control" {...rest} />
|
||||
{error ? <p className="app-field__error">{error}</p> : hint}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppSelect({
|
||||
label,
|
||||
id,
|
||||
hint,
|
||||
error,
|
||||
className = "",
|
||||
children,
|
||||
...rest
|
||||
}: SelectHTMLAttributes<HTMLSelectElement> & FieldProps & { id: string }) {
|
||||
return (
|
||||
<div className={`app-field ${error ? "app-field--error" : ""} ${className}`.trim()}>
|
||||
{label ? (
|
||||
<label htmlFor={id}>
|
||||
<AppSectionHeader>{label}</AppSectionHeader>
|
||||
</label>
|
||||
) : null}
|
||||
<select id={id} className="app-field__control" {...rest}>
|
||||
{children}
|
||||
</select>
|
||||
{error ? <p className="app-field__error">{error}</p> : hint}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export { AppText, AppSectionHeader } from "./AppText";
|
||||
export { AppButton } from "./AppButton";
|
||||
export type { AppButtonSize, AppButtonStyle } from "./AppButton";
|
||||
export { AppTextField, AppSelect } from "./AppTextField";
|
||||
export { AppListSection, AppListTile } from "./AppList";
|
||||
export { AppSegmented } from "./AppSegmented";
|
||||
export {
|
||||
AppSpinner,
|
||||
AppSkeleton,
|
||||
AppProgress,
|
||||
AppEmptyView,
|
||||
AppErrorView,
|
||||
AppChip,
|
||||
AppAvatar,
|
||||
} from "./AppFeedback";
|
||||
@@ -0,0 +1,346 @@
|
||||
/* iOS kit — mirrors mobile/lib/ui */
|
||||
|
||||
.app-text {
|
||||
margin: 0;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
.app-text--largeTitle {
|
||||
font-size: var(--text-large-title);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.37px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.app-text--title1 {
|
||||
font-size: var(--text-title1);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.36px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.app-text--title2 {
|
||||
font-size: var(--text-title2);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.35px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.app-text--title3 {
|
||||
font-size: var(--text-title3);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.38px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.app-text--headline {
|
||||
font-size: var(--text-headline);
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.41px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.app-text--body {
|
||||
font-size: var(--text-body);
|
||||
font-weight: 400;
|
||||
letter-spacing: -0.41px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.app-text--callout {
|
||||
font-size: var(--text-callout);
|
||||
font-weight: 400;
|
||||
letter-spacing: -0.32px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.app-text--subhead {
|
||||
font-size: var(--text-subhead);
|
||||
font-weight: 400;
|
||||
letter-spacing: -0.24px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.app-text--footnote {
|
||||
font-size: var(--text-footnote);
|
||||
font-weight: 400;
|
||||
letter-spacing: -0.08px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.app-text--caption {
|
||||
font-size: var(--text-caption1);
|
||||
font-weight: 400;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.app-text--label { color: var(--label); }
|
||||
.app-text--secondary { color: var(--secondary-label); }
|
||||
.app-text--tertiary { color: var(--tertiary-label); }
|
||||
.app-text--accent { color: var(--accent); }
|
||||
.app-text--danger { color: var(--system-red); }
|
||||
|
||||
.app-section-header {
|
||||
display: block;
|
||||
margin: 0;
|
||||
padding: var(--space-4) var(--gutter) var(--space-2);
|
||||
font-size: var(--text-footnote);
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.4px;
|
||||
text-transform: uppercase;
|
||||
color: var(--secondary-label);
|
||||
}
|
||||
|
||||
.app-section-footer {
|
||||
margin: 0;
|
||||
padding: var(--space-2) var(--gutter) 0;
|
||||
font-size: var(--text-footnote);
|
||||
color: var(--secondary-label);
|
||||
}
|
||||
|
||||
.app-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.41px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: filter 140ms ease, opacity 140ms ease;
|
||||
}
|
||||
|
||||
.app-btn:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.app-btn--expanded { width: 100%; }
|
||||
.app-btn--large {
|
||||
height: var(--control-height);
|
||||
padding: 0 var(--space-4);
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: var(--text-headline);
|
||||
}
|
||||
.app-btn--medium {
|
||||
height: var(--control-height-md);
|
||||
padding: 0 var(--space-4);
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: var(--text-body);
|
||||
}
|
||||
.app-btn--small {
|
||||
height: var(--control-height-sm);
|
||||
padding: 0 var(--space-3);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-subhead);
|
||||
}
|
||||
|
||||
.app-btn--filled { background: var(--accent); color: #fff; }
|
||||
.app-btn--tinted { background: rgb(18 136 90 / 15%); color: var(--accent); }
|
||||
.app-btn--gray { background: var(--system-gray5); color: var(--label); }
|
||||
.app-btn--plain { background: transparent; color: var(--accent); }
|
||||
.app-btn--destructive { background: rgb(255 59 48 / 15%); color: var(--system-red); }
|
||||
|
||||
.app-btn:hover:not(:disabled) { filter: brightness(1.04); }
|
||||
|
||||
.app-field { display: grid; gap: var(--space-2); }
|
||||
.app-field .app-section-header {
|
||||
padding: 0;
|
||||
}
|
||||
.app-field__control {
|
||||
width: 100%;
|
||||
height: var(--control-height-md);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--grouped-surface);
|
||||
color: var(--label);
|
||||
padding: 0 var(--space-3);
|
||||
font-size: var(--text-body);
|
||||
letter-spacing: -0.41px;
|
||||
}
|
||||
.app-field__control:disabled {
|
||||
opacity: 0.4;
|
||||
}
|
||||
.app-field--error .app-field__control {
|
||||
box-shadow: inset 0 0 0 1px var(--system-red);
|
||||
}
|
||||
.app-field__error {
|
||||
margin: 0;
|
||||
font-size: var(--text-footnote);
|
||||
color: var(--system-red);
|
||||
}
|
||||
|
||||
.app-list {
|
||||
margin: 0 var(--gutter);
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
background: var(--grouped-surface);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-list--flush {
|
||||
margin-inline: 0;
|
||||
}
|
||||
|
||||
.app-tile {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
width: 100%;
|
||||
min-height: var(--row-min-height);
|
||||
padding: 10px var(--gutter);
|
||||
border: 0;
|
||||
background: var(--grouped-surface);
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
cursor: default;
|
||||
}
|
||||
button.app-tile,
|
||||
a.app-tile {
|
||||
cursor: pointer;
|
||||
}
|
||||
button.app-tile:hover,
|
||||
a.app-tile:hover {
|
||||
background: var(--system-gray5);
|
||||
}
|
||||
.app-tile + .app-tile {
|
||||
box-shadow: inset 0 var(--hairline) 0 var(--separator);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
.app-tile__body { flex: 1; min-width: 0; }
|
||||
.app-tile__title {
|
||||
margin: 0;
|
||||
font-size: var(--text-body);
|
||||
letter-spacing: -0.41px;
|
||||
color: var(--label);
|
||||
}
|
||||
.app-tile--destructive .app-tile__title { color: var(--system-red); }
|
||||
.app-tile__subtitle {
|
||||
margin: 2px 0 0;
|
||||
font-size: var(--text-footnote);
|
||||
color: var(--secondary-label);
|
||||
}
|
||||
.app-tile__value {
|
||||
margin: 0;
|
||||
font-size: var(--text-body);
|
||||
color: var(--secondary-label);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.app-tile__chevron {
|
||||
color: var(--tertiary-label);
|
||||
font-size: 17px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.app-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 28px;
|
||||
padding: 0 12px;
|
||||
border: 0;
|
||||
border-radius: var(--radius-capsule);
|
||||
background: var(--system-gray5);
|
||||
color: var(--label);
|
||||
font-size: var(--text-subhead);
|
||||
cursor: pointer;
|
||||
}
|
||||
.app-chip.is-on {
|
||||
background: rgb(18 136 90 / 15%);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.app-avatar {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: var(--radius-capsule);
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
font-size: var(--text-subhead);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.app-progress__track {
|
||||
height: 4px;
|
||||
border-radius: var(--radius-capsule);
|
||||
background: var(--system-gray5);
|
||||
overflow: hidden;
|
||||
}
|
||||
.app-progress__bar {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
border-radius: inherit;
|
||||
}
|
||||
.app-progress__bar.is-warn { background: var(--system-orange); }
|
||||
.app-progress__bar.is-danger { background: var(--system-red); }
|
||||
|
||||
.app-spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid var(--system-gray3);
|
||||
border-top-color: var(--secondary-label);
|
||||
border-radius: 50%;
|
||||
animation: app-spin 700ms linear infinite;
|
||||
}
|
||||
@keyframes app-spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.app-skeleton {
|
||||
display: block;
|
||||
height: 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--system-gray5);
|
||||
}
|
||||
|
||||
.app-empty,
|
||||
.app-error {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-7) var(--gutter);
|
||||
text-align: center;
|
||||
color: var(--secondary-label);
|
||||
}
|
||||
|
||||
.app-segmented {
|
||||
display: flex;
|
||||
padding: 2px;
|
||||
border-radius: 9px;
|
||||
background: var(--system-gray5);
|
||||
}
|
||||
.app-segmented__item {
|
||||
flex: 1;
|
||||
min-height: 32px;
|
||||
border: 0;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: var(--label);
|
||||
font-size: var(--text-subhead);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
.app-segmented__item.is-on {
|
||||
background: var(--grouped-surface);
|
||||
box-shadow: 0 1px 3px rgb(0 0 0 / 12%);
|
||||
}
|
||||
|
||||
.app-toast {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: 24px;
|
||||
transform: translateX(-50%);
|
||||
z-index: var(--z-toast);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
min-height: 44px;
|
||||
padding: 0 16px;
|
||||
border-radius: var(--radius-capsule);
|
||||
background: rgb(28 28 30 / 88%);
|
||||
color: #fff;
|
||||
font-size: var(--text-subhead);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.app-btn--tinted { background: rgb(60 214 140 / 18%); }
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": { target: "http://127.0.0.1:5088", changeOrigin: true },
|
||||
"/u": { target: "http://127.0.0.1:5088", changeOrigin: true }
|
||||
}
|
||||
},
|
||||
build: {
|
||||
outDir: "dist",
|
||||
emptyOutDir: true
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user