Крутой дизайн

This commit is contained in:
 Илья Шалонин 2025-12-19 14:11:32 +05:00
parent ae1229d550
commit 3d65d2b7e8
5 changed files with 326 additions and 329 deletions

View file

@ -61,6 +61,7 @@ body, #root {
.layout {
min-height: 100vh;
height: 100vh;
background: var(--bg);
color: var(--text);
display: flex;
@ -70,94 +71,51 @@ body, #root {
.content {
width: 100%;
max-width: 1200px;
margin: 0 auto;
max-width: none;
margin: 0;
display: flex;
flex-direction: column;
overflow-y: hidden;
overflow: hidden;
flex: 1 1 auto;
min-height: 0;
}
.topbar {
display: flex;
justify-content: space-between;
border-radius: 0 0 14px 14px;
align-items: center;
padding: 18px 22px;
border-bottom: 1px solid var(--glass-border);
backdrop-filter: blur(10px);
background: var(--glass);
}
.brand {
display: flex;
gap: 12px;
align-items: center;
}
.pulse {
width: 12px;
height: 12px;
border-radius: 50%;
background: #f59e0b;
animation: pulse 2.4s infinite;
}
.pulse.ok {
background: #34d399;
}
@keyframes pulse {
0% {
transform: scale(1);
}
50% {
transform: scale(1.2);
}
100% {
transform: scale(1);
}
}
.title {
font-weight: 700;
letter-spacing: 0.4px;
}
.subtitle {
color: var(--muted);
font-size: 13px;
}
.top-actions {
display: flex;
gap: 10px;
align-items: center;
.status-strip {
flex: 0 0 auto;
padding: 6px 10px;
background: var(--surface);
border-bottom: 1px solid var(--border);
font-size: 12px;
}
.mini-info {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
padding: 0 12px;
grid-template-columns: 1fr 1fr 1fr;
gap: 8px;
padding: 0;
}
.mini-info.compact {
align-items: stretch;
}
.chip {
padding: 10px 12px;
border-radius: 12px;
padding: 4px 10px;
border-radius: 10px;
background: var(--surface-strong);
border: 1px solid var(--border);
display: flex;
flex-direction: column;
gap: 4px;
gap: 2px;
}
.leaflet-container{
height: 100% !important;
}
.chip.small span {
font-size: 12px;
}
.chip span {
font-size: 13px;
color: var(--muted);
}
.chip strong {
font-size: 18px;
.chip.small strong {
font-size: 16px;
}
.pill {
@ -190,15 +148,21 @@ body, #root {
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
grid-template-columns: repeat(3, 1fr);
gap: 12px;
padding-bottom: 32px;
}
.card {
padding: 12px;
width: 141px;
height: 141px;
border-radius: 12px;
background: var(--surface-strong);
border: 1px solid var(--border);
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
}
.card-label {
@ -213,26 +177,38 @@ body, #root {
color: var(--text);
}
.map-shell {
flex: 1 1 auto;
min-height: 0;
padding: 0;
}
.map-panel {
padding: 12px;
padding: 0;
display: flex;
flex-direction: column;
gap: 12px;
gap: 0;
flex: 1 1 auto;
min-height: 0;
background: transparent;
border: none;
}
.map {
height: 70vh;
border-radius: 12px;
height: 100%;
min-height: 0;
border-radius: 0;
overflow: hidden;
border: 1px solid var(--border);
border: none;
width: 100%;
}
.maplibre {
height: 70vh;
border-radius: 12px;
height: 100%;
min-height: 0;
border-radius: 0;
overflow: hidden;
border: 1px solid var(--border);
border: none;
width: 100%;
}
@ -256,7 +232,8 @@ body, #root {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 14px;
padding: 32px 0;
border-top: 1px solid var(--border);
}
.panel-title {
@ -293,6 +270,7 @@ body, #root {
display: flex;
flex-direction: column;
gap: 12px;
padding-bottom: 80px;
}
.field {
@ -369,10 +347,11 @@ body, #root {
gap: 10px;
flex-wrap: wrap;
align-items: center;
justify-content: end;
}
.field.actions {
grid-template-columns: 180px 1fr;
grid-template-columns: 1fr 1fr;
background: var(--surface);
}
@ -429,6 +408,44 @@ body, #root {
border-color: var(--btn-ghost-border);
}
.bottom-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0;
width: 100%;
height: 10vh;
min-height: 64px;
max-height: 120px;
}
.bottom-settings{
display: grid;
grid-template-columns: 1fr;
gap: 0;
width: 100%;
height: 10vh;
min-height: 64px;
max-height: 120px;
position: absolute;
bottom: 0;
left: 0;
}
.bottom-settings .btn.block {
width: 100%;
height: 100%;
border-radius: 0;
font-size: 16px;
}
.bottom-actions .btn.block {
width: 100%;
height: 100%;
border-radius: 0;
font-size: 16px;
}
.bottom-actions .btn.ghost {
border-right: 1px solid var(--border);
}
.settings-backdrop {
position: fixed;
inset: 0;
@ -476,7 +493,7 @@ body, #root {
.settings-content {
overflow: auto;
padding: 0 18px 18px;
padding: 0;
display: flex;
flex-direction: column;
gap: 12px;

View file

@ -5,7 +5,6 @@ import "maplibre-gl/dist/maplibre-gl.css";
import { MapPanel } from "./components/MapPanel";
import { SettingsPanel } from "./components/SettingsPanel";
import { Topbar } from "./components/Topbar";
import { TrackSummary } from "./components/TrackSummary";
import { useVectorMap } from "./hooks/useVectorMap";
import { getInitialTheme } from "./utils/theme";
@ -309,11 +308,12 @@ export default function App() {
return (
<div className="layout">
<Topbar status={status} onOpenSettings={() => setSettingsOpen(true)} />
<div className="status-strip">
<TrackSummary lastPoint={lastPoint} pointsCount={track.length} compact />
</div>
<main className="content">
<section className="panel map-panel">
<TrackSummary lastPoint={lastPoint} pointsCount={track.length} />
<main className="content map-shell">
<section className="panel map-panel flat">
<MapPanel
mapMode={mapMode}
tileUrl={tileUrl}
@ -325,6 +325,19 @@ export default function App() {
</section>
</main>
<div className="bottom-actions">
<button
type="button"
className="btn ghost block"
onClick={() => setMapMode((m) => (m === "raster" ? "vector" : "raster"))}
>
Режим карты: {mapMode === "raster" ? "Растровые" : "Векторные"}
</button>
<button type="button" className="btn primary block" onClick={() => setSettingsOpen(true)}>
Меню настроек
</button>
</div>
<SettingsPanel
open={settingsOpen}
onClose={() => setSettingsOpen(false)}
@ -332,8 +345,6 @@ export default function App() {
status={status}
theme={theme}
onToggleTheme={() => setTheme((t) => (t === "dark" ? "light" : "dark"))}
mapMode={mapMode}
onMapModeChange={setMapMode}
tilesets={tilesets}
activeTileset={activeTileset}
onActivateTileset={(name) => activateTileset(name).catch((err) => sendFrontLog(`${err}`, "error"))}

View file

@ -1,6 +1,6 @@
import type { MutableRefObject, RefObject } from "react";
import { FormEvent } from "react";
import type { AppConfig, MapMode, SliceParams } from "../types";
import type { AppConfig, SliceParams } from "../types";
type SettingsPanelProps = {
open: boolean;
@ -9,8 +9,6 @@ type SettingsPanelProps = {
status: string;
theme: "dark" | "light";
onToggleTheme: () => void;
mapMode: MapMode;
onMapModeChange: (mode: MapMode) => void;
tilesets: string[];
activeTileset: string;
onActivateTileset: (name: string) => void;
@ -34,8 +32,6 @@ export const SettingsPanel = ({
status,
theme,
onToggleTheme,
mapMode,
onMapModeChange,
tilesets,
activeTileset,
onActivateTileset,
@ -59,9 +55,6 @@ export const SettingsPanel = ({
<div className="panel-title">Настройки</div>
<div className="panel-subtitle">Управление источниками и нарезкой</div>
</div>
<button type="button" className="btn primary small" onClick={onClose}>
Закрыть
</button>
</div>
<div className="settings-content">
@ -80,213 +73,205 @@ export const SettingsPanel = ({
<div className="card-value">{status}</div>
</div>
</div>
<div className="panel-header">
<div>
<div className="panel-title">Тема</div>
<div className="panel-subtitle">Светлая или тёмная</div>
</div>
<button type="button" className="btn ghost small" onClick={onToggleTheme}>
{theme === "dark" ? "Включить светлую" : "Включить тёмную"}
</button>
</div>
<div className="panel-header">
<div>
<div className="panel-title">Наборы тайлов</div>
<div className="panel-subtitle">Активация и обновление</div>
</div>
</div>
<div className="tileset-row stacked">
<select value={activeTileset} onChange={(e) => onActivateTileset(e.target.value)}>
{tilesets.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
<button type="button" className="btn ghost" onClick={onRefreshTilesets}>
Обновить
</button>
</div>
<div className="panel-header">
<div>
<div className="panel-title">Нарезка тайлов</div>
<div className="panel-subtitle">Запускает gdal2tiles на бэкенде</div>
</div>
<div className={`badge ${sliceStatus === "ok" ? "ok" : sliceStatus === "idle" ? "" : "warn"}`}>
{sliceStatus}
</div>
</div>
<form
className="slice-grid"
onSubmit={(event) => {
event.preventDefault();
onSliceSubmit(event);
}}
>
<label className="field">
<span>Источник (GeoTIFF/MBTiles)</span>
<div className="file-row">
<input
type="text"
placeholder="/path/to/input.tif"
value={sliceParams.source}
onChange={(e) => onSliceParamsChange({ ...sliceParams, source: e.target.value })}
required
/>
<button type="button" className="btn ghost" onClick={onTriggerFileSelect}>
Выбрать
</button>
<input
type="file"
accept=".tif,.tiff,.mbtiles,.jpg,.jpeg,.png"
ref={fileInputRef}
style={{ display: "none" }}
onChange={(e) => onFileSelected(e.target.files?.[0])}
/>
</div>
{selectedFileName && <small className="muted">Файл: {selectedFileName}</small>}
</label>
<label className="field">
<span>Схема тайлов</span>
<select
value={sliceParams.scheme}
onChange={(e) => onSliceParamsChange({ ...sliceParams, scheme: e.target.value as "xyz" | "tms" })}
>
<option value="xyz">XYZ (Y сверху, Leaflet)</option>
<option value="tms">TMS (Y снизу)</option>
</select>
</label>
<label className="field">
<span>Формат исходника</span>
<select
value={sliceParams.source_format}
onChange={(e) =>
onSliceParamsChange({
...sliceParams,
source_format: e.target.value as "geotiff" | "mbtiles" | "tms" | "xyz",
})
}
>
<option value="geotiff">GeoTIFF/растровый TIFF</option>
<option value="mbtiles">MBTiles (растровый)</option>
<option value="tms">Папка TMS</option>
<option value="xyz">Папка XYZ</option>
</select>
</label>
<label className="field">
<span>Tileset (имя папки)</span>
<div className="file-row">
<input
type="text"
list="tilesets-list"
placeholder="например: moscow (пусто → активный)"
value={sliceParams.tileset}
onChange={(e) => onSliceParamsChange({ ...sliceParams, tileset: e.target.value })}
/>
<button
type="button"
className="btn ghost"
onClick={() => {
const name = sliceParams.tileset.trim();
if (!name) return;
onCreateTileset(name);
}}
>
Создать
</button>
</div>
<datalist id="tilesets-list">
{tilesets.map((t) => (
<option key={t} value={t} />
))}
</datalist>
<small className="muted">Активный: {activeTileset || "—"}</small>
</label>
<label className="field">
<span>Папка тайлов (опц.)</span>
<input
type="text"
placeholder="лучше не трогать (по умолчанию tileset)"
value={sliceParams.destination}
onChange={(e) => onSliceParamsChange({ ...sliceParams, destination: e.target.value })}
/>
</label>
<label className="field">
<span>Min zoom</span>
<input
type="number"
min={0}
value={sliceParams.min_zoom}
onChange={(e) =>
onSliceParamsChange({
...sliceParams,
min_zoom: Number(e.target.value),
})
}
required
/>
</label>
<label className="field">
<span>Max zoom</span>
<input
type="number"
min={0}
value={sliceParams.max_zoom}
onChange={(e) =>
onSliceParamsChange({
...sliceParams,
max_zoom: Number(e.target.value),
})
}
required
/>
</label>
<label className="field">
<span>Формат тайлов (сейчас поддерживается PNG)</span>
<select
value={sliceParams.tile_format}
onChange={(e) => onSliceParamsChange({ ...sliceParams, tile_format: e.target.value })}
>
<option value="png">png</option>
</select>
</label>
<divz>
<div className="actions-row">
<button type="submit" className="btn primary">
Запустить
</button>
<button type="button" className="btn ghost" onClick={onSliceReset}>
Сброс
</button>
</div>
</divz>
</form>
</section>
<section className="panel info">
<div className="panel-header">
<div>
<div className="panel-title">Тема</div>
<div className="panel-subtitle">Светлая или тёмная</div>
</div>
<button type="button" className="btn ghost small" onClick={onToggleTheme}>
{theme === "dark" ? "Включить светлую" : "Включить тёмную"}
</button>
<div className="bottom-settings">
<button type="button" className="btn primary block" onClick={onClose}>
Закрыть
</button>
</div>
<label className="field compact">
<span>Режим карты</span>
<select value={mapMode} onChange={(e) => onMapModeChange(e.target.value as MapMode)}>
<option value="raster">Растровые</option>
<option value="vector">Векторные</option>
</select>
</label>
</section>
<section className="panel">
<div className="panel-header">
<div>
<div className="panel-title">Наборы тайлов</div>
<div className="panel-subtitle">Активация и обновление</div>
</div>
</div>
<div className="tileset-row stacked">
<select value={activeTileset} onChange={(e) => onActivateTileset(e.target.value)}>
{tilesets.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
<button type="button" className="btn ghost" onClick={onRefreshTilesets}>
Обновить
</button>
</div>
</section>
<section className="panel slice-panel">
<div className="panel-header">
<div>
<div className="panel-title">Нарезка тайлов</div>
<div className="panel-subtitle">Запускает gdal2tiles на бэкенде</div>
</div>
<div className={`badge ${sliceStatus === "ok" ? "ok" : sliceStatus === "idle" ? "" : "warn"}`}>
{sliceStatus}
</div>
</div>
<form
className="slice-grid"
onSubmit={(event) => {
event.preventDefault();
onSliceSubmit(event);
}}
>
<label className="field">
<span>Источник (GeoTIFF/MBTiles)</span>
<div className="file-row">
<input
type="text"
placeholder="/path/to/input.tif"
value={sliceParams.source}
onChange={(e) => onSliceParamsChange({ ...sliceParams, source: e.target.value })}
required
/>
<button type="button" className="btn ghost" onClick={onTriggerFileSelect}>
Выбрать
</button>
<input
type="file"
accept=".tif,.tiff,.mbtiles,.jpg,.jpeg,.png"
ref={fileInputRef}
style={{ display: "none" }}
onChange={(e) => onFileSelected(e.target.files?.[0])}
/>
</div>
{selectedFileName && <small className="muted">Файл: {selectedFileName}</small>}
</label>
<label className="field">
<span>Схема тайлов</span>
<select
value={sliceParams.scheme}
onChange={(e) => onSliceParamsChange({ ...sliceParams, scheme: e.target.value as "xyz" | "tms" })}
>
<option value="xyz">XYZ (Y сверху, Leaflet)</option>
<option value="tms">TMS (Y снизу)</option>
</select>
</label>
<label className="field">
<span>Формат исходника</span>
<select
value={sliceParams.source_format}
onChange={(e) =>
onSliceParamsChange({
...sliceParams,
source_format: e.target.value as "geotiff" | "mbtiles" | "tms" | "xyz",
})
}
>
<option value="geotiff">GeoTIFF/растровый TIFF</option>
<option value="mbtiles">MBTiles (растровый)</option>
<option value="tms">Папка TMS</option>
<option value="xyz">Папка XYZ</option>
</select>
</label>
<label className="field">
<span>Tileset (имя папки)</span>
<div className="file-row">
<input
type="text"
list="tilesets-list"
placeholder="например: moscow (пусто → активный)"
value={sliceParams.tileset}
onChange={(e) => onSliceParamsChange({ ...sliceParams, tileset: e.target.value })}
/>
<button
type="button"
className="btn ghost"
onClick={() => {
const name = sliceParams.tileset.trim();
if (!name) return;
onCreateTileset(name);
}}
>
Создать
</button>
</div>
<datalist id="tilesets-list">
{tilesets.map((t) => (
<option key={t} value={t} />
))}
</datalist>
<small className="muted">Активный: {activeTileset || "—"}</small>
</label>
<label className="field">
<span>Папка тайлов (опц.)</span>
<input
type="text"
placeholder="лучше не трогать (по умолчанию tileset)"
value={sliceParams.destination}
onChange={(e) => onSliceParamsChange({ ...sliceParams, destination: e.target.value })}
/>
</label>
<label className="field">
<span>Min zoom</span>
<input
type="number"
min={0}
value={sliceParams.min_zoom}
onChange={(e) =>
onSliceParamsChange({
...sliceParams,
min_zoom: Number(e.target.value),
})
}
required
/>
</label>
<label className="field">
<span>Max zoom</span>
<input
type="number"
min={0}
value={sliceParams.max_zoom}
onChange={(e) =>
onSliceParamsChange({
...sliceParams,
max_zoom: Number(e.target.value),
})
}
required
/>
</label>
<label className="field">
<span>Формат тайлов (сейчас поддерживается PNG)</span>
<select
value={sliceParams.tile_format}
onChange={(e) => onSliceParamsChange({ ...sliceParams, tile_format: e.target.value })}
>
<option value="png">png</option>
</select>
</label>
<div className="field actions">
<span>Действия</span>
<div className="actions-row">
<button type="submit" className="btn primary">
Запустить
</button>
<button type="button" className="btn ghost" onClick={onSliceReset}>
Сброс
</button>
</div>
</div>
</form>
</section>
</div>
</aside>
</>

View file

@ -1,21 +0,0 @@
type TopbarProps = {
status: string;
onOpenSettings: () => void;
};
export const Topbar = ({ status, onOpenSettings }: TopbarProps) => (
<header className="topbar">
<div className="brand">
<div className={`pulse ${status === "ok" ? "ok" : ""}`} />
<div>
<div className="title">GoAgro</div>
<div className="subtitle">Тестовая версия</div>
</div>
</div>
<div className="top-actions">
<button type="button" className="btn primary small" onClick={onOpenSettings}>
Настройки
</button>
</div>
</header>
);

View file

@ -4,17 +4,22 @@ import type { TrackPoint } from "../types";
type TrackSummaryProps = {
lastPoint: TrackPoint | null;
pointsCount: number;
compact?: boolean;
};
export const TrackSummary = ({ lastPoint, pointsCount }: TrackSummaryProps) => (
<div className="mini-info">
<div className="chip">
export const TrackSummary = ({ lastPoint, pointsCount, compact }: TrackSummaryProps) => (
<div className={`mini-info ${compact ? "compact" : ""}`}>
<div className="chip small">
<span>Последняя фиксация</span>
<strong>{formatTime(lastPoint?.timestamp)}</strong>
</div>
<div className="chip">
<div className="chip small">
<span>Точек трека</span>
<strong>{pointsCount}</strong>
</div>
<div className="chip small">
<span>Статус</span>
<strong>ок</strong>
</div>
</div>
);