dev
Platon Yasev 3 years ago
parent 1352b2a080
commit 5313a8f5dd

@ -9,6 +9,8 @@
"preview": "vite preview"
},
"dependencies": {
"@ant-design/icons": "^5.0.1",
"@nanostores/react": "^0.4.1",
"@turf/bbox": "^6.5.0",
"@turf/helpers": "^6.5.0",
"@watergis/maplibre-gl-export": "^1.3.7",
@ -17,11 +19,14 @@
"immer": "^9.0.16",
"mapbox-gl": "npm:empty-npm-package@1.0.0",
"maplibre-gl": "^2.4.0",
"nanostores": "^0.7.3",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-icons": "^4.6.0",
"react-map-gl": "^7.0.19",
"react-router-dom": "^6.8.1",
"tailwind-merge": "^1.7.0",
"vite-plugin-svgr": "^2.4.0",
"zustand": "^4.1.3"
},
"devDependencies": {

@ -1,8 +1,21 @@
import "./App.css";
import { MapComponent } from "./Map/MapComponent";
import { BrowserRouter, Route, Routes } from "react-router-dom";
import { LoginPage } from "./pages/Login";
import { VerifyRegistrationPage } from "./pages/VerifyRegistration";
import { RegisterPage } from "./pages/Register";
import { MapPage } from "./pages/Map";
function App() {
return <MapComponent />;
return (
<BrowserRouter basename={import.meta.env.BASE_URL}>
<Routes>
<Route path="/" element={<MapPage />} />
<Route path="/signin" element={<LoginPage />} />
<Route path="/verify-user" element={<VerifyRegistrationPage />} />
<Route path="/register" element={<RegisterPage />} />
</Routes>
</BrowserRouter>
);
}
export default App;

@ -1,4 +1,3 @@
import { Grid } from "./Grid";
import { useRating } from "../stores/useRating";
import { Points } from "./Points";
import { Layer, Source } from "react-map-gl";
@ -13,7 +12,6 @@ export const Layers = () => {
return (
<>
<Grid rate={rate} />
<Source
id="ao"
type="vector"

@ -1,56 +1,12 @@
import maplibregl from "maplibre-gl";
import Map, { useControl } from "react-map-gl";
import Map from "react-map-gl";
import { useRef, useState } from "react";
import { Sidebar } from "../modules/Sidebar/Sidebar";
import { Layers } from "./Layers";
import { MapPopup } from "./Popup";
import { MaplibreExportControl } from "@watergis/maplibre-gl-export";
import { Basemap } from "./Basemap";
import { Legend } from "../modules/Sidebar/Legend";
const ruTranslation = {
PageSize: "Размер",
PageOrientation: "Ориентация",
Format: "Формат",
DPI: "DPI",
Generate: "Экспорт карты",
};
class CustomMaplibreExportControl extends MaplibreExportControl {
constructor(options) {
super(options);
}
getTranslation() {
if (this.options.Local === "ru") {
return ruTranslation;
}
return super.getTranslation();
}
}
// const MAP_TILER_KEY = "hE7PBueqYiS7hKSYUXP9";
const ExportControl = (props) => {
const control = useRef(null);
useControl(
() => {
const controlInstance = new CustomMaplibreExportControl(props);
control.current = controlInstance;
return controlInstance;
},
{
position: props.position,
}
);
// useEffect(() => {
// console.log(control.current);
// }, []);
return null;
};
import { SignOut } from "../SignOut";
export const MapComponent = () => {
const mapRef = useRef(null);
@ -113,28 +69,11 @@ export const MapComponent = () => {
}}
dragRotate={false}
ref={mapRef}
interactiveLayerIds={[
"point3",
"point4",
"point5",
"grid3",
"grid4",
"grid5",
]}
interactiveLayerIds={["point3", "point4", "point5"]}
onClick={handleClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<ExportControl
position="top-left"
// PageSize={Size.A4}
// PageOrientation={PageOrientation.Landscape}
// Format={Format.PNG}
// DPI={DPI[200]}
Crosshair={true}
PrintableArea={true}
Local={"ru"}
/>
{clickedFeature && popupCoordinates && (
<MapPopup
lat={popupCoordinates[1]}
@ -153,6 +92,7 @@ export const MapComponent = () => {
<Sidebar />
<Legend />
<SignOut />
</Map>
);
};

@ -0,0 +1,22 @@
import { Button } from "antd";
import { LogoutOutlined } from "@ant-design/icons";
import { setToken } from "./stores/auth";
import { api } from "./api";
export function SignOut() {
return (
<div className="absolute top-[20px] right-[20px]">
<Button
icon={<LogoutOutlined />}
type="primary"
size="large"
title="Выйти"
onClick={async () => {
await api.post("accounts/logout/");
setToken("");
}}
/>
</div>
);
}

@ -0,0 +1,21 @@
import { useStore } from "@nanostores/react";
import { Spin } from "antd";
import { Navigate } from "react-router-dom";
import { isAuthorized$, userInfoLoading$ } from "./stores/auth";
export function WithAuth(props) {
const isAuthorized = useStore(isAuthorized$);
const userInfoLoading = useStore(userInfoLoading$);
if (userInfoLoading) {
return <Spin className="user-info-loader" />;
}
if (isAuthorized) {
return <>{props.children}</>;
}
return <Navigate to="/signin" replace={true} />;
}

@ -0,0 +1,11 @@
import axios from "axios";
export const api = axios.create({
baseURL:
import.meta.env.MODE === "development"
? "http://localhost:5173/"
: "https://property.spatiality.website/",
withCredentials: true,
xsrfHeaderName: "X-CSRFToken",
xsrfCookieName: "csrftoken",
});

@ -1,5 +1,3 @@
import axios from "axios";
export const TYPE_MAPPER = {
kiosk: "Городской киоск",
mfc: "МФЦ",
@ -28,7 +26,3 @@ export const factorsNameMapper = {
service: "Пункты оказания бытовых услуг",
vuz: "ВУЗы и техникумы",
};
export const api = axios.create({
baseURL: "https://postamates.spatiality.website",
});

@ -15,14 +15,6 @@ export const LayersVisibility = () => {
>
Точки размещения постаматов
</Checkbox>
<Checkbox
className={"!ml-0"}
onChange={() => toggleVisibility("grid")}
checked={isVisible.grid}
>
Тепловая карта
</Checkbox>
<Checkbox
className={"!ml-0"}
onChange={() => toggleVisibility("atd")}

@ -11,7 +11,7 @@ const types = [
export function Legend() {
return (
<div className="absolute bottom-[20px] left-[20px] bg-white-background w-[250px] rounded-xl p-4 text-xs text-grey z-10">
<div className="absolute bottom-[20px] right-[20px] bg-white-background w-[250px] rounded-xl p-4 text-xs text-grey z-10">
<Title text={"Тип объекта размещения"} className={"text-center"} />
<Title
text={"Размер кружка пропорционален востребованности точки"}

@ -2,11 +2,11 @@ import { Empty, TreeSelect } from "antd";
import { Title } from "../../components/Title";
import { useRegion } from "../../stores/useRegion";
import { useEffect, useMemo, useState } from "react";
import { api } from "../../config";
import { useMap } from "react-map-gl";
import getBbox from "@turf/bbox";
import { polygon as getPolygon } from "@turf/helpers";
import { useRegionGeometry } from "../../stores/useRegionGeometry";
import { api } from "../../api";
const { TreeNode } = TreeSelect;
@ -36,7 +36,9 @@ export const RegionSelect = () => {
const getRegions = async () => {
setLoading(true);
try {
const response = await api.get("/api/ao_and_rayons");
const response = await api.get(
"https://postamates.spatiality.website/api/ao_and_rayons"
);
setData(response.data);
} catch (err) {
console.error(err);

@ -1,20 +1,16 @@
import { RegionSelect } from "./RegionSelect";
import { Button, Popover } from "antd";
import { BsChevronLeft } from "react-icons/bs";
import { Button } from "antd";
import { ObjectTypesSelect } from "./ObjectTypesSelect";
import { RatingSlider } from "./RatingSlider";
import { LayersVisibility } from "./LayersVisibility";
import { GridSizeSelect } from "./GridSizeSelect";
import { ModelSelect } from "./ModelSelect";
import { Settings } from "./Settings";
import { useFactors } from "../../stores/useFactors";
import { useActiveTypes } from "../../stores/useActiveTypes";
import { useRegion } from "../../stores/useRegion";
import { useGridSize } from "../../stores/useGridSize";
import { api } from "../../config";
import { useRating } from "../../stores/useRating";
import { useState } from "react";
import { useModel } from "../../stores/useModel";
import { api } from "../../api";
const activeTablesMapper = {
net_3: ["point3", "net_3"],
@ -98,25 +94,8 @@ export const Sidebar = () => {
};
return (
<div className="absolute top-[20px] right-[20px] bg-white-background w-[300px] rounded-xl p-3 max-h-[calc(100vh-40px)] overflow-y-auto z-10">
<Popover
placement="leftTop"
title={"Веса факторов"}
content={Settings}
trigger="click"
>
<Button
type="text"
icon={<BsChevronLeft className="mr-3" />}
className="flex items-center p-0 pr-1 text-grey mb-2 hover:bg-transparent focus:bg-transparent"
>
Настройки
</Button>
</Popover>
<div className="absolute top-[20px] left-[20px] bg-white-background w-[300px] rounded-xl p-3 max-h-[calc(100vh-40px)] overflow-y-auto z-10">
<div className="space-y-5">
<ModelSelect />
<GridSizeSelect />
<LayersVisibility />
<RegionSelect />
<ObjectTypesSelect />

@ -0,0 +1,80 @@
import { useStore } from "@nanostores/react";
import { Alert, Button, Form, Input, Space, Typography } from "antd";
import { LockOutlined, UserOutlined } from "@ant-design/icons";
import React from "react";
import { Link, Navigate } from "react-router-dom";
import { isAuthorized$ } from "../stores/auth";
import { signin, signinError$, signinLoading$ } from "../stores/signin";
function LoginForm() {
const signinError = useStore(signinError$);
const signinLoading = useStore(signinLoading$);
const onFinish = (values) => {
signin(values);
};
return (
<Space direction="vertical" style={{ width: "320px" }}>
{signinError.length > 0 ? (
<Alert type="error" showIcon closable description={signinError} />
) : null}
<Typography.Title level={4}>Вход</Typography.Title>
<Form
disabled={signinLoading}
name="basic"
layout="vertical"
onFinish={onFinish}
autoComplete="off"
>
<Form.Item
label="Логин"
name="login"
rules={[{ required: true, message: "Обязательное поле" }]}
>
<Input
prefix={<UserOutlined className="site-form-item-icon" />}
placeholder="логин"
/>
</Form.Item>
<Form.Item
label="Пароль"
name="password"
rules={[{ required: true, message: "Обязательное поле" }]}
>
<Input
prefix={<LockOutlined className="site-form-item-icon" />}
type="password"
placeholder="пароль"
/>
</Form.Item>
<Form.Item>
<Button block type="primary" htmlType="submit">
Войти
</Button>
</Form.Item>
<div style={{ textAlign: "center" }}>
<Link to="/register">Регистрация</Link>
</div>
</Form>
</Space>
);
}
export function LoginPage() {
const isAuthorized = useStore(isAuthorized$);
if (isAuthorized) {
return <Navigate to="/" replace={true} />;
} else {
return (
<main className="w-screen h-screen flex items-center justify-center">
<LoginForm />
</main>
);
}
}

@ -0,0 +1,10 @@
import { WithAuth } from "../WithAuth";
import { MapComponent } from "../Map/MapComponent";
export function MapPage() {
return (
<WithAuth>
<MapComponent />
</WithAuth>
);
}

@ -0,0 +1,149 @@
import { useStore } from "@nanostores/react";
import { Alert, Button, Form, Input, Result, Space, Typography } from "antd";
import { LockOutlined, MailOutlined, UserOutlined } from "@ant-design/icons";
import React, { useState } from "react";
import { Link, Navigate } from "react-router-dom";
import { isAuthorized$ } from "../stores/auth";
import { register, signupError$, signupLoading$ } from "../stores/signin";
function isObject(obj) {
var type = typeof obj;
return type === "function" || (type === "object" && !!obj);
}
function RegisterForm() {
const [form] = Form.useForm();
const [successMessage, setSuccessMessage] = useState("");
const signupError = useStore(signupError$);
const signupLoading = useStore(signupLoading$);
const onFinish = (values) => {
register(values)
.then(() => {
setSuccessMessage(
`Пользователь успешно зарегистрирован. Проверьте почту ${values.email} для активации аккаунта.`
);
})
.catch((e) => {
if (isObject(e.errors)) {
form.setFields(e.errors);
}
});
};
if (successMessage) {
return (
<Result
title={successMessage}
status="success"
extra={<Link to="/">На главную</Link>}
/>
);
}
return (
<Space direction="vertical" style={{ width: "320px" }}>
{signupError.length > 0 ? (
<Alert type="error" showIcon closable description={signupError} />
) : null}
<Typography.Title level={4}>Регистрация</Typography.Title>
<Form
form={form}
disabled={signupLoading}
name="basic"
layout="vertical"
onFinish={onFinish}
>
<Form.Item
name="username"
label="Логин"
rules={[{ required: true, message: "Обязательное поле" }]}
>
<Input
prefix={<UserOutlined className="site-form-item-icon" />}
placeholder=""
/>
</Form.Item>
<Form.Item
name="email"
label="Email"
rules={[
{
required: true,
type: "email",
message: "Введите корректный email",
},
]}
>
<Input
type="email"
autoComplete="email"
prefix={<MailOutlined className="site-form-item-icon" />}
placeholder=""
/>
</Form.Item>
<Form.Item
name="password"
label="Пароль"
rules={[{ required: true, message: "Обязательное поле" }]}
>
<Input
prefix={<LockOutlined className="site-form-item-icon" />}
type="password"
autoComplete="new-password"
placeholder=""
/>
</Form.Item>
<Form.Item
name="password_confirm"
label="Пароль еще раз"
rules={[
{ required: true, message: "Обязательное поле" },
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue("password") === value) {
return Promise.resolve();
}
return Promise.reject(new Error("Пароли не совпадают"));
},
}),
]}
>
<Input
autoComplete="re-password"
prefix={<LockOutlined className="site-form-item-icon" />}
type="password"
placeholder=""
/>
</Form.Item>
<Form.Item>
<Button block type="primary" htmlType="submit">
Зарегистрироваться
</Button>
</Form.Item>
<div style={{ textAlign: "center" }}>
<Link to="/">Уже есть аккаунт</Link>
</div>
</Form>
</Space>
);
}
export function RegisterPage() {
const isAuthorized = useStore(isAuthorized$);
if (isAuthorized) {
return <Navigate to="/" replace={true} />;
} else {
return (
<main className="h-screen w-screen flex items-center justify-center">
<RegisterForm />
</main>
);
}
}

@ -0,0 +1,30 @@
import { useState } from "react";
import { Navigate, useSearchParams } from "react-router-dom";
import { api } from "../api";
import { useEffectOnce } from "../useEffectOnce";
export function VerifyRegistrationPage() {
const [params] = useSearchParams();
const [isVerified, setIsVerified] = useState(false);
useEffectOnce(() => {
async function verify() {
try {
await api.post("accounts/verify-registration/", {
user_id: params.get("user_id"),
timestamp: params.get("timestamp"),
signature: params.get("signature"),
});
} finally {
setIsVerified(true);
}
}
verify();
}, []);
if (!isVerified) {
return <div>Verifying...</div>;
}
return <Navigate to="/" />;
}

@ -0,0 +1,25 @@
import { action, atom, computed } from "nanostores";
import { api } from "../api";
export const token$ = atom("");
export const userInfoLoading$ = atom(true);
export const isAuthorized$ = computed([token$], (token) => token !== "");
export const setToken = action(token$, "setToken", (store, newValue) => {
store.set(newValue);
});
async function checkAuth() {
try {
await api.get("/accounts/profile/");
setToken("123456");
} catch (e) {
console.log("Not authorized");
} finally {
userInfoLoading$.set(false);
}
}
checkAuth();

@ -0,0 +1,78 @@
import { atom } from "nanostores";
import { api } from "../api";
import { setToken } from "./auth";
export class DjangoValidationError extends Error {
errors;
constructor(errObject) {
super("");
this.name = "DjangoValidationError";
const fieldsErrors = [];
for (const field in errObject) {
fieldsErrors.push({
name: field,
errors: errObject[field],
});
}
this.errors = fieldsErrors;
}
}
export const signinLoading$ = atom(false);
export const signinError$ = atom("");
const DEFAULT_ERROR = "Невозможно войти с предоставленными учетными данными.";
export async function signin(values) {
signinLoading$.set(true);
signinError$.set("");
try {
const { data } = await api.post("accounts/login/", values);
setToken("123456");
return data;
} catch (e) {
var error = DEFAULT_ERROR;
if (e.response?.data?.detail) {
error = e.response?.data?.detail;
signinError$.set(error);
}
throw new DjangoValidationError(e.response.data);
} finally {
signinLoading$.set(false);
}
}
export const resetSignin = function () {};
export const signupLoading$ = atom(false);
export const signupError$ = atom("");
export async function register(values) {
signupLoading$.set(true);
try {
const { data } = await api.post("accounts/register/", values);
return data;
} catch (e) {
if (e.response.data?.non_field_errors) {
signupError$.set(e.response.data.non_field_errors.join(" "));
}
throw new DjangoValidationError(e.response.data);
} finally {
signupLoading$.set(false);
}
}
export const resetSignup = function () {
signupError$.set("");
};

@ -2,8 +2,7 @@ import create from "zustand";
import { immer } from "zustand/middleware/immer";
const INITIAL_STATE = {
points: false,
grid: true,
points: true,
atd: true,
};

@ -0,0 +1,37 @@
import { useEffect, useRef, useState } from "react";
export const useEffectOnce = (effect) => {
const effectFn = useRef(effect);
const destroyFn = useRef();
const effectCalled = useRef(false);
const rendered = useRef(false);
const [, setVal] = useState(0);
if (effectCalled.current) {
rendered.current = true;
}
useEffect(() => {
// only execute the effect first time around
if (!effectCalled.current) {
destroyFn.current = effectFn.current();
effectCalled.current = true;
}
// this forces one render after the effect is run
setVal((val) => val + 1);
return () => {
// if the comp didn't render since the useEffect was called,
// we know it's the dummy React cycle
if (!rendered.current) {
return;
}
// otherwise this is not a dummy destroy, so call the destroy func
if (destroyFn.current) {
destroyFn.current();
}
};
}, []);
};

@ -1,10 +1,17 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import svgr from "vite-plugin-svgr";
// https://vitejs.dev/config/
export default defineConfig({
base: "/",
plugins: [react()],
plugins: [svgr(), react()],
server: {
proxy: {
"/account": "https://property.spatiality.website/",
"/api": "https://property.spatiality.website/",
},
},
css: {
preprocessorOptions: {
less: {

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save