Крутой дизайн
This commit is contained in:
parent
ae1229d550
commit
3d65d2b7e8
5 changed files with 326 additions and 329 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"))}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue