Some checks failed
CI / changes (push) Successful in 8s
CI / backend-tests (push) Has been skipped
CI / frontend-tests (push) Successful in 1m3s
CI / changes (pull_request) Successful in 6s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 50s
Deploy / changes (push) Has been cancelled
Deploy / build-frontend (push) Has been cancelled
Deploy / deploy (push) Has been cancelled
Deploy / build-backend (push) Has been cancelled
Deploy / build-worker (push) Has been cancelled
#66: responsive CSS-layer в globals.css (3-контекста <768/768-1280/>1280, не Tailwind sm/md/lg) + ResizeObserver в ChartShell (единственный echarts импортёр → все charts рефлоу) + class-применение (grid-collapse, nav horizontal-scroll, map-stack) на site-finder/analytics. overflow-x guard. #80: /data-sources attribution-страница (OSM ODbL/ДОМ.РФ/Объектив/Open-Meteo/Росреестр/ НСПД, server component, tokens) + footer-link. Closes #66 Closes #80
228 lines
6.5 KiB
TypeScript
228 lines
6.5 KiB
TypeScript
"use client";
|
||
|
||
import dynamic from "next/dynamic";
|
||
import Link from "next/link";
|
||
import { useRouter } from "next/navigation";
|
||
import { useState } from "react";
|
||
|
||
import { CadInput } from "@/components/site-finder/CadInput";
|
||
import { MapFilterBar } from "@/components/site-finder/entry/MapFilterBar";
|
||
import { ParcelDrawer } from "@/components/site-finder/entry/ParcelDrawer";
|
||
import { ParcelLegend } from "@/components/site-finder/entry/ParcelLegend";
|
||
import { RecentParcels } from "@/components/site-finder/entry/RecentParcels";
|
||
import {
|
||
addLocalRecentParcel,
|
||
useParcelsBboxQuery,
|
||
} from "@/lib/site-finder-api";
|
||
import type {
|
||
ParcelBboxFilters,
|
||
ParcelBboxItem,
|
||
BboxCoords,
|
||
} from "@/lib/site-finder-api";
|
||
|
||
// EntryMap uses Leaflet — must load without SSR
|
||
const EntryMap = dynamic(
|
||
() =>
|
||
import("@/components/site-finder/entry/EntryMap").then((m) => m.EntryMap),
|
||
{
|
||
ssr: false,
|
||
loading: () => (
|
||
<div
|
||
style={{
|
||
flex: 1,
|
||
minHeight: 480,
|
||
background: "var(--bg-card-alt)",
|
||
border: "1px dashed var(--border-strong)",
|
||
borderRadius: 12,
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
color: "var(--fg-tertiary)",
|
||
fontSize: 14,
|
||
}}
|
||
>
|
||
Загрузка карты...
|
||
</div>
|
||
),
|
||
},
|
||
);
|
||
|
||
// ── FilterCountBridge ──────────────────────────────────────────────────────────
|
||
// Reads parcel counts for current bbox without re-mounting EntryMap.
|
||
|
||
interface FilterBarBridgeProps {
|
||
filters: ParcelBboxFilters;
|
||
onChange: (f: ParcelBboxFilters) => void;
|
||
bbox: BboxCoords | null;
|
||
}
|
||
|
||
function FilterBarBridge({ filters, onChange, bbox }: FilterBarBridgeProps) {
|
||
const { data: allInBbox } = useParcelsBboxQuery(bbox, {});
|
||
const { data: filtered } = useParcelsBboxQuery(bbox, filters);
|
||
|
||
return (
|
||
<MapFilterBar
|
||
filters={filters}
|
||
onChange={onChange}
|
||
totalCount={allInBbox?.length ?? 0}
|
||
matchCount={filtered?.length ?? 0}
|
||
/>
|
||
);
|
||
}
|
||
|
||
// ── Page ──────────────────────────────────────────────────────────────────────
|
||
|
||
export default function SiteFinderPage() {
|
||
const router = useRouter();
|
||
const [filters, setFilters] = useState<ParcelBboxFilters>({});
|
||
const [selectedParcel, setSelectedParcel] = useState<ParcelBboxItem | null>(
|
||
null,
|
||
);
|
||
const [bbox, setBbox] = useState<BboxCoords | null>(null);
|
||
|
||
function handleParcelSelect(parcel: ParcelBboxItem) {
|
||
setSelectedParcel(parcel);
|
||
}
|
||
|
||
function handleParcelDeselect() {
|
||
setSelectedParcel(null);
|
||
}
|
||
|
||
function handleParcelOpen(parcel: ParcelBboxItem) {
|
||
// Record the visit so RecentParcels stays populated, then navigate.
|
||
addLocalRecentParcel({
|
||
cad_num: parcel.cad_num,
|
||
address: parcel.address,
|
||
area_ha: parcel.area_ha,
|
||
district: parcel.district,
|
||
visited_at: new Date().toISOString(),
|
||
});
|
||
router.push(`/site-finder/analysis/${encodeURIComponent(parcel.cad_num)}`);
|
||
}
|
||
|
||
function handleCadSubmit(cad: string) {
|
||
router.push(`/site-finder/analysis/${encodeURIComponent(cad)}`);
|
||
}
|
||
|
||
return (
|
||
<main
|
||
style={{
|
||
minHeight: "100vh",
|
||
background: "var(--bg-app)",
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
}}
|
||
>
|
||
{/* Header */}
|
||
<header
|
||
style={{
|
||
background: "var(--bg-card)",
|
||
borderBottom: "1px solid var(--border-card)",
|
||
padding: "12px 24px",
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: 16,
|
||
flexShrink: 0,
|
||
}}
|
||
>
|
||
<Link
|
||
href="/"
|
||
style={{
|
||
fontSize: 13,
|
||
color: "var(--fg-secondary)",
|
||
textDecoration: "none",
|
||
}}
|
||
>
|
||
← Главная
|
||
</Link>
|
||
<span style={{ color: "var(--border-soft)" }}>·</span>
|
||
<h1
|
||
style={{
|
||
margin: 0,
|
||
fontSize: 16,
|
||
fontWeight: 600,
|
||
color: "var(--fg-primary)",
|
||
}}
|
||
>
|
||
SiteFinder · карта участков
|
||
</h1>
|
||
<Link
|
||
href="/site-finder/compare"
|
||
style={{
|
||
marginLeft: "auto",
|
||
fontSize: 13,
|
||
color: "var(--accent)",
|
||
textDecoration: "none",
|
||
fontWeight: 500,
|
||
}}
|
||
>
|
||
Сравнить участки
|
||
</Link>
|
||
</header>
|
||
|
||
{/* Filter bar */}
|
||
<div style={{ flexShrink: 0 }}>
|
||
<FilterBarBridge filters={filters} onChange={setFilters} bbox={bbox} />
|
||
</div>
|
||
|
||
{/* Main layout. На планшете/телефоне .gd-split встаёт в колонку
|
||
(карта над сайдбаром), .gd-map-shell держит высоту карты (issue #66). */}
|
||
<div
|
||
className="gd-split"
|
||
style={{
|
||
flex: 1,
|
||
display: "flex",
|
||
gap: 0,
|
||
overflow: "hidden",
|
||
minHeight: 0,
|
||
}}
|
||
>
|
||
{/* Map area — position: relative so ParcelDrawer can anchor absolutely here */}
|
||
<div
|
||
className="gd-map-shell"
|
||
style={{
|
||
flex: 1,
|
||
padding: 16,
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
minHeight: 0,
|
||
position: "relative",
|
||
}}
|
||
>
|
||
<EntryMap
|
||
filters={filters}
|
||
selectedCad={selectedParcel?.cad_num ?? null}
|
||
onParcelOpen={handleParcelOpen}
|
||
onParcelSelect={handleParcelSelect}
|
||
onParcelDeselect={handleParcelDeselect}
|
||
onBboxChange={setBbox}
|
||
/>
|
||
|
||
{/* ParcelDrawer — anchored to map area right edge, not page */}
|
||
<ParcelDrawer
|
||
parcel={selectedParcel}
|
||
onClose={handleParcelDeselect}
|
||
/>
|
||
</div>
|
||
|
||
{/* Right sidebar — always visible; CadInput accessible parallel with drawer */}
|
||
<div
|
||
className="gd-split-aside"
|
||
style={{
|
||
width: 300,
|
||
flexShrink: 0,
|
||
padding: "16px 16px 16px 0",
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
gap: 12,
|
||
overflowY: "auto",
|
||
}}
|
||
>
|
||
<CadInput onSubmit={handleCadSubmit} loading={false} />
|
||
<RecentParcels />
|
||
<ParcelLegend />
|
||
</div>
|
||
</div>
|
||
</main>
|
||
);
|
||
}
|