feature/webgl-preview-update (#21)
Co-authored-by: itoshi <kkzemeow@gmail.com> Reviewed-on: #21
This commit was merged in pull request #21.
This commit is contained in:
@@ -35,3 +35,4 @@ const languageInstance = (language: Language) => {
|
||||
};
|
||||
|
||||
export { authInstance, languageInstance };
|
||||
export { mobxFetch } from "./mobxFetch";
|
||||
|
||||
183
src/shared/api/mobxFetch/index.ts
Normal file
183
src/shared/api/mobxFetch/index.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import { runInAction } from "mobx";
|
||||
|
||||
type mobxFetchOptions<RequestType, ResponseType, Store> = {
|
||||
store: Store;
|
||||
value?: keyof Store;
|
||||
values?: Array<keyof Store>;
|
||||
loading?: keyof Store;
|
||||
error?: keyof Store;
|
||||
fn: RequestType extends void
|
||||
? (signal?: AbortSignal) => Promise<ResponseType>
|
||||
: (request: RequestType, signal?: AbortSignal) => Promise<ResponseType>;
|
||||
|
||||
pollingInterval?: number;
|
||||
resetValue?: boolean;
|
||||
transform?: (response: ResponseType) => Partial<Record<string, any>>;
|
||||
onSuccess?: (response: ResponseType) => void;
|
||||
};
|
||||
|
||||
type FetchFunction<RequestType, ResponseType> = RequestType extends void
|
||||
? {
|
||||
(): Promise<ResponseType | null>;
|
||||
stopPolling?: () => void;
|
||||
}
|
||||
: {
|
||||
(request: RequestType): Promise<ResponseType | null>;
|
||||
stopPolling?: () => void;
|
||||
};
|
||||
|
||||
export function mobxFetch<ResponseType, Store extends Record<string, any>>(
|
||||
options: mobxFetchOptions<void, ResponseType, Store>
|
||||
): FetchFunction<void, ResponseType>;
|
||||
|
||||
export function mobxFetch<
|
||||
RequestType,
|
||||
ResponseType,
|
||||
Store extends Record<string, any>,
|
||||
>(
|
||||
options: mobxFetchOptions<RequestType, ResponseType, Store>
|
||||
): FetchFunction<RequestType, ResponseType>;
|
||||
|
||||
export function mobxFetch<
|
||||
RequestType,
|
||||
ResponseType,
|
||||
Store extends Record<string, any>,
|
||||
>(
|
||||
options: mobxFetchOptions<RequestType, ResponseType, Store>
|
||||
): FetchFunction<RequestType, ResponseType> {
|
||||
const {
|
||||
store,
|
||||
value,
|
||||
values,
|
||||
loading,
|
||||
error,
|
||||
fn,
|
||||
pollingInterval,
|
||||
resetValue,
|
||||
transform,
|
||||
onSuccess,
|
||||
} = options;
|
||||
|
||||
let abortController: AbortController | undefined;
|
||||
let pollingTimer: ReturnType<typeof setInterval> | undefined;
|
||||
let currentRequest: RequestType | undefined;
|
||||
|
||||
const stopPolling = () => {
|
||||
if (pollingTimer) {
|
||||
clearInterval(pollingTimer);
|
||||
pollingTimer = undefined;
|
||||
}
|
||||
abortController?.abort();
|
||||
};
|
||||
|
||||
const fetch = async (request?: RequestType): Promise<ResponseType | null> => {
|
||||
abortController?.abort();
|
||||
abortController = new AbortController();
|
||||
currentRequest = request as RequestType;
|
||||
|
||||
runInAction(() => {
|
||||
if (value) {
|
||||
(store[value] as any) = resetValue ? null : store[value];
|
||||
}
|
||||
|
||||
if (values) {
|
||||
values.forEach((key) => {
|
||||
(store[key] as any) = resetValue ? null : store[key];
|
||||
});
|
||||
}
|
||||
|
||||
if (error) {
|
||||
(store[error] as any) = null;
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
(store[loading] as any) = true;
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await (
|
||||
fn as (
|
||||
request?: RequestType,
|
||||
signal?: AbortSignal
|
||||
) => Promise<ResponseType>
|
||||
)(request, abortController.signal);
|
||||
|
||||
runInAction(() => {
|
||||
if (values && transform) {
|
||||
const transformed = transform(result) as Record<string, any>;
|
||||
values.forEach((key) => {
|
||||
const k = key as string;
|
||||
if (k in transformed) {
|
||||
(store[key] as any) = transformed[k];
|
||||
}
|
||||
});
|
||||
} else if (value) {
|
||||
(store[value] as any) = result as ResponseType;
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
(store[loading] as any) = false;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
(store[error] as any) = null;
|
||||
}
|
||||
});
|
||||
|
||||
if (pollingInterval && !pollingTimer) {
|
||||
pollingTimer = setInterval(() => {
|
||||
if (currentRequest !== undefined) {
|
||||
fetch(currentRequest);
|
||||
} else {
|
||||
fetch();
|
||||
}
|
||||
}, pollingInterval);
|
||||
}
|
||||
|
||||
if (onSuccess) {
|
||||
onSuccess(result);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (err) {
|
||||
if (!(err instanceof Error && err.name === "CanceledError")) {
|
||||
runInAction(() => {
|
||||
if (error) {
|
||||
(store[error] as any) =
|
||||
err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
(store[loading] as any) = false;
|
||||
}
|
||||
|
||||
if (value) {
|
||||
(store[value] as any) = null;
|
||||
}
|
||||
|
||||
if (values) {
|
||||
values.forEach((key) => {
|
||||
(store[key] as any) = null;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
throw err;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchWithStopPolling = fetch as FetchFunction<
|
||||
RequestType,
|
||||
ResponseType
|
||||
>;
|
||||
|
||||
if (pollingInterval) {
|
||||
fetchWithStopPolling.stopPolling = stopPolling;
|
||||
}
|
||||
|
||||
return fetchWithStopPolling;
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
export const CarrierSvg = () => {
|
||||
return (
|
||||
<svg
|
||||
fill="#000000"
|
||||
height="26px"
|
||||
width="26px"
|
||||
version="1.1"
|
||||
id="Capa_1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlnsXlink="http://www.w3.org/1999/xlink"
|
||||
viewBox="0 0 489.785 489.785"
|
||||
>
|
||||
<g id="XMLID_196_">
|
||||
<path
|
||||
id="XMLID_203_"
|
||||
d="M409.772,379.327l-81.359-124.975c-5.884-9.054-15.925-13.119-25.987-13.119
|
||||
c-2.082,0-6.392,0.05-11.051,0.115c-0.363-0.61-0.742-1.215-1.355-1.627l-20.492-13.609c-2.364-1.569-5.434-1.486-7.701,0.182
|
||||
l-16.948,12.508l-16.959-12.508c-2.285-1.668-5.337-1.751-7.72-0.182l-20.455,13.609c-0.578,0.377-0.945,0.907-1.282,1.461
|
||||
c-4.828,0.031-9.327,0.057-11.222,0.057c-10.016,0-20.011,4.119-25.859,13.113L80.022,379.327
|
||||
c-8.65,13.267-5.149,31.008,7.896,39.992l18.06,12.449c10.887-25.926,28.868-48.094,51.45-64.279l4.657-7.162v3.861
|
||||
c16.364-10.811,34.941-18.477,54.885-22.234c-5.926-13.152-10.899-28.819-14.546-43.586c-4.249-17.232-6.741-33.201-6.741-42.245
|
||||
c0-3.351,0.433-6.579,1.09-9.727l14.8,48.975c0.766,2.565,2.984,4.417,5.641,4.73c0.268,0.03,0.529,0.046,0.784,0.046
|
||||
c2.365,0,4.602-1.25,5.818-3.34l11.538-19.873l3.246,3.235c-7.768,10.276-10.82,39.199-12.005,60.314
|
||||
c5.994-0.734,12.066-1.222,18.254-1.222c6.201,0,12.292,0.497,18.304,1.23c-1.186-21.114-4.237-50.037-12.024-60.322l3.246-3.255
|
||||
l11.574,19.892c1.216,2.09,3.422,3.34,5.805,3.34c0.255,0,0.522-0.016,0.779-0.046c2.655-0.314,4.874-2.166,5.659-4.73
|
||||
l14.791-48.872c0.634,3.116,1.051,6.313,1.051,9.624c0,16.806-8.425,57.342-21.276,85.831
|
||||
c19.981,3.768,38.588,11.453,54.953,22.291v-3.899l4.735,7.256c22.504,16.193,40.436,38.324,51.293,64.206l18.139-12.488
|
||||
C414.919,410.335,418.403,392.594,409.772,379.327z M219.962,276.685l-8.613-28.53l12.388-8.24l12.322,9.088L219.962,276.685z
|
||||
M269.783,276.685l-16.079-27.683l12.31-9.088l12.401,8.24L269.783,276.685z"
|
||||
/>
|
||||
<path
|
||||
id="XMLID_202_"
|
||||
d="M202.716,424.721l14.705,19.349c8.151-4.914,17.598-7.607,27.427-7.607c9.848,0,19.313,2.692,27.464,7.615
|
||||
l14.705-19.363c-11.465-10.799-26.346-16.721-42.15-16.721C229.055,407.994,214.156,413.925,202.716,424.721z"
|
||||
/>
|
||||
<path
|
||||
id="XMLID_201_"
|
||||
d="M176.693,160.576c0.499,25.456,14.96,47.266,36.03,58.591c9.622,5.18,20.473,8.384,32.174,8.384
|
||||
c11.683,0,22.503-3.198,32.114-8.368c21.063-11.311,35.579-33.117,36.06-58.582c-17.379,12.075-41.896,19.923-68.174,19.923
|
||||
S194.096,172.676,176.693,160.576z"
|
||||
/>
|
||||
<path
|
||||
id="XMLID_200_"
|
||||
d="M174.741,100.132l-0.225,20.205c0.037,15.991,31.524,36.82,70.38,36.82
|
||||
c38.855,0,70.314-20.829,70.331-36.82l-0.207-20.195c10.224-2.662,18.158-6.617,23.239-12.301
|
||||
c3.981-4.434,6.267-9.902,6.267-16.783C344.528,39.883,299.879,0,244.897,0c-55.031,0-99.631,39.883-99.631,71.058
|
||||
c0,6.881,2.273,12.34,6.236,16.783C156.585,93.524,164.529,97.479,174.741,100.132z"
|
||||
/>
|
||||
<path
|
||||
id="XMLID_197_"
|
||||
d="M244.848,356.925c-73.255,0-132.858,59.605-132.858,132.86h33.47c0-0.048,0-0.114,0-0.161v-0.031
|
||||
c1.088-6.557,6.711-11.334,13.313-11.334c0.115,0,0.243,0.01,0.37,0.01l51.707,1.341c-0.973,3.247-1.648,6.619-1.648,10.176h71.322
|
||||
c0-3.557-0.669-6.929-1.66-10.176l51.724-1.341c0.109,0,0.219-0.01,0.353-0.01c6.595,0,12.243,4.777,13.324,11.334v0.031
|
||||
c0,0.047,0,0.113,0,0.161h33.44C377.706,416.53,318.122,356.925,244.848,356.925z M302.201,433.91l-27.562,36.317
|
||||
c-6.389-9.687-17.325-16.104-29.792-16.104c-12.437,0-23.385,6.411-29.762,16.098l-27.555-36.3
|
||||
c-4.699-6.194-4.11-14.923,1.392-20.424c15.452-15.443,35.689-23.166,55.943-23.166c20.249,0,40.484,7.723,55.961,23.179
|
||||
C306.322,419.007,306.901,427.719,302.201,433.91z"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
58
src/shared/config/carrier.svg
Normal file
58
src/shared/config/carrier.svg
Normal file
@@ -0,0 +1,58 @@
|
||||
<svg
|
||||
fill="#000000"
|
||||
height="26px"
|
||||
width="26px"
|
||||
version="1.1"
|
||||
id="Capa_1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlnsXlink="http://www.w3.org/1999/xlink"
|
||||
viewBox="0 0 489.785 489.785"
|
||||
>
|
||||
<g id="XMLID_196_">
|
||||
<path
|
||||
id="XMLID_203_"
|
||||
d="M409.772,379.327l-81.359-124.975c-5.884-9.054-15.925-13.119-25.987-13.119
|
||||
c-2.082,0-6.392,0.05-11.051,0.115c-0.363-0.61-0.742-1.215-1.355-1.627l-20.492-13.609c-2.364-1.569-5.434-1.486-7.701,0.182
|
||||
l-16.948,12.508l-16.959-12.508c-2.285-1.668-5.337-1.751-7.72-0.182l-20.455,13.609c-0.578,0.377-0.945,0.907-1.282,1.461
|
||||
c-4.828,0.031-9.327,0.057-11.222,0.057c-10.016,0-20.011,4.119-25.859,13.113L80.022,379.327
|
||||
c-8.65,13.267-5.149,31.008,7.896,39.992l18.06,12.449c10.887-25.926,28.868-48.094,51.45-64.279l4.657-7.162v3.861
|
||||
c16.364-10.811,34.941-18.477,54.885-22.234c-5.926-13.152-10.899-28.819-14.546-43.586c-4.249-17.232-6.741-33.201-6.741-42.245
|
||||
c0-3.351,0.433-6.579,1.09-9.727l14.8,48.975c0.766,2.565,2.984,4.417,5.641,4.73c0.268,0.03,0.529,0.046,0.784,0.046
|
||||
c2.365,0,4.602-1.25,5.818-3.34l11.538-19.873l3.246,3.235c-7.768,10.276-10.82,39.199-12.005,60.314
|
||||
c5.994-0.734,12.066-1.222,18.254-1.222c6.201,0,12.292,0.497,18.304,1.23c-1.186-21.114-4.237-50.037-12.024-60.322l3.246-3.255
|
||||
l11.574,19.892c1.216,2.09,3.422,3.34,5.805,3.34c0.255,0,0.522-0.016,0.779-0.046c2.655-0.314,4.874-2.166,5.659-4.73
|
||||
l14.791-48.872c0.634,3.116,1.051,6.313,1.051,9.624c0,16.806-8.425,57.342-21.276,85.831
|
||||
c19.981,3.768,38.588,11.453,54.953,22.291v-3.899l4.735,7.256c22.504,16.193,40.436,38.324,51.293,64.206l18.139-12.488
|
||||
C414.919,410.335,418.403,392.594,409.772,379.327z M219.962,276.685l-8.613-28.53l12.388-8.24l12.322,9.088L219.962,276.685z
|
||||
M269.783,276.685l-16.079-27.683l12.31-9.088l12.401,8.24L269.783,276.685z"
|
||||
/>
|
||||
<path
|
||||
id="XMLID_202_"
|
||||
d="M202.716,424.721l14.705,19.349c8.151-4.914,17.598-7.607,27.427-7.607c9.848,0,19.313,2.692,27.464,7.615
|
||||
l14.705-19.363c-11.465-10.799-26.346-16.721-42.15-16.721C229.055,407.994,214.156,413.925,202.716,424.721z"
|
||||
/>
|
||||
<path
|
||||
id="XMLID_201_"
|
||||
d="M176.693,160.576c0.499,25.456,14.96,47.266,36.03,58.591c9.622,5.18,20.473,8.384,32.174,8.384
|
||||
c11.683,0,22.503-3.198,32.114-8.368c21.063-11.311,35.579-33.117,36.06-58.582c-17.379,12.075-41.896,19.923-68.174,19.923
|
||||
S194.096,172.676,176.693,160.576z"
|
||||
/>
|
||||
<path
|
||||
id="XMLID_200_"
|
||||
d="M174.741,100.132l-0.225,20.205c0.037,15.991,31.524,36.82,70.38,36.82
|
||||
c38.855,0,70.314-20.829,70.331-36.82l-0.207-20.195c10.224-2.662,18.158-6.617,23.239-12.301
|
||||
c3.981-4.434,6.267-9.902,6.267-16.783C344.528,39.883,299.879,0,244.897,0c-55.031,0-99.631,39.883-99.631,71.058
|
||||
c0,6.881,2.273,12.34,6.236,16.783C156.585,93.524,164.529,97.479,174.741,100.132z"
|
||||
/>
|
||||
<path
|
||||
id="XMLID_197_"
|
||||
d="M244.848,356.925c-73.255,0-132.858,59.605-132.858,132.86h33.47c0-0.048,0-0.114,0-0.161v-0.031
|
||||
c1.088-6.557,6.711-11.334,13.313-11.334c0.115,0,0.243,0.01,0.37,0.01l51.707,1.341c-0.973,3.247-1.648,6.619-1.648,10.176h71.322
|
||||
c0-3.557-0.669-6.929-1.66-10.176l51.724-1.341c0.109,0,0.219-0.01,0.353-0.01c6.595,0,12.243,4.777,13.324,11.334v0.031
|
||||
c0,0.047,0,0.113,0,0.161h33.44C377.706,416.53,318.122,356.925,244.848,356.925z M302.201,433.91l-27.562,36.317
|
||||
c-6.389-9.687-17.325-16.104-29.792-16.104c-12.437,0-23.385,6.411-29.762,16.098l-27.555-36.3
|
||||
c-4.699-6.194-4.11-14.923,1.392-20.424c15.452-15.443,35.689-23.166,55.943-23.166c20.249,0,40.484,7.723,55.961,23.179
|
||||
C306.322,419.007,306.901,427.719,302.201,433.91z"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.4 KiB |
@@ -1,4 +1,4 @@
|
||||
import { authStore } from "@shared";
|
||||
import { authStore, snapshotStore } from "@shared";
|
||||
import {
|
||||
Power,
|
||||
LucideIcon,
|
||||
@@ -8,15 +8,15 @@ import {
|
||||
Earth,
|
||||
Landmark,
|
||||
GitBranch,
|
||||
// Car,
|
||||
Table,
|
||||
Split,
|
||||
// Newspaper,
|
||||
PersonStanding,
|
||||
Cpu,
|
||||
// BookImage,
|
||||
RefreshCcw,
|
||||
} from "lucide-react";
|
||||
import { CarrierSvg } from "./CarrierSvg";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
import carrierIcon from "./carrier.svg";
|
||||
|
||||
export const DRAWER_WIDTH = 300;
|
||||
|
||||
@@ -25,12 +25,63 @@ interface NavigationItem {
|
||||
label: string;
|
||||
icon?: LucideIcon | React.ReactNode;
|
||||
path?: string;
|
||||
for_admin?: boolean;
|
||||
requiredRoles?: string[];
|
||||
onClick?: () => void;
|
||||
nestedItems?: NavigationItem[];
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export const ROUTE_REQUIRED_RESOURCES: Record<string, string[]> = {
|
||||
"/": [],
|
||||
|
||||
"/sight": ["sights"],
|
||||
"/sight/create": ["sights"],
|
||||
"/sight/:id/edit": ["sights"],
|
||||
|
||||
"/devices": ["devices", "vehicles", "routes", "carriers", "snapshot_rw"],
|
||||
|
||||
"/map": ["map"],
|
||||
|
||||
"/media": ["sights"],
|
||||
"/media/:id": ["sights"],
|
||||
"/media/:id/edit": ["sights"],
|
||||
|
||||
"/country": ["countries"],
|
||||
"/country/create": ["countries"],
|
||||
"/country/add": ["countries"],
|
||||
"/country/:id/edit": ["countries"],
|
||||
|
||||
"/city": ["cities", "countries"],
|
||||
"/city/create": ["cities", "countries"],
|
||||
"/city/:id/edit": ["cities", "countries"],
|
||||
|
||||
"/route": ["routes", "carriers"],
|
||||
"/route/create": ["routes", "carriers"],
|
||||
"/route/:id/edit": ["routes", "carriers"],
|
||||
|
||||
"/user": ["users"],
|
||||
"/user/create": ["users"],
|
||||
"/user/:id/edit": ["users"],
|
||||
|
||||
"/snapshot": ["snapshot_rw"],
|
||||
"/snapshot/create": ["snapshot_create", "devices_rw"],
|
||||
|
||||
"/carrier": ["carriers"],
|
||||
"/carrier/create": ["carriers"],
|
||||
"/carrier/:id/edit": ["carriers"],
|
||||
|
||||
"/station": ["stations"],
|
||||
"/station/create": ["stations"],
|
||||
"/station/:id": ["stations"],
|
||||
"/station/:id/edit": ["stations"],
|
||||
|
||||
"/vehicle/create": ["devices"],
|
||||
"/vehicle/:id/edit": ["devices"],
|
||||
|
||||
"/article": ["sights"],
|
||||
"/article/:id": ["sights"],
|
||||
};
|
||||
|
||||
export const NAVIGATION_ITEMS: {
|
||||
primary: NavigationItem[];
|
||||
secondary: NavigationItem[];
|
||||
@@ -38,10 +89,10 @@ export const NAVIGATION_ITEMS: {
|
||||
primary: [
|
||||
{
|
||||
id: "snapshots",
|
||||
label: "Снапшоты",
|
||||
label: "Экспорт",
|
||||
icon: GitBranch,
|
||||
path: "/snapshot",
|
||||
for_admin: true,
|
||||
requiredRoles: ["snapshot_rw", "snapshot_create"],
|
||||
},
|
||||
{
|
||||
id: "map",
|
||||
@@ -54,55 +105,40 @@ export const NAVIGATION_ITEMS: {
|
||||
label: "Устройства",
|
||||
icon: Cpu,
|
||||
path: "/devices",
|
||||
for_admin: true,
|
||||
requiredRoles: ["devices_ro", "devices_rw"],
|
||||
},
|
||||
// {
|
||||
// id: "vehicles",
|
||||
// label: "Транспорт",
|
||||
// icon: Car,
|
||||
// path: "/vehicle",
|
||||
// },
|
||||
{
|
||||
id: "users",
|
||||
label: "Пользователи",
|
||||
icon: Users,
|
||||
path: "/user",
|
||||
for_admin: true,
|
||||
requiredRoles: ["users_ro", "users_rw"],
|
||||
},
|
||||
{
|
||||
id: "all",
|
||||
label: "Справочник",
|
||||
icon: Table,
|
||||
nestedItems: [
|
||||
// {
|
||||
// id: "media",
|
||||
// label: "Медиа",
|
||||
// icon: BookImage,
|
||||
// path: "/media",
|
||||
// },
|
||||
// {
|
||||
// id: "articles",
|
||||
// label: "Статьи",
|
||||
// icon: Newspaper,
|
||||
// path: "/article",
|
||||
// },
|
||||
{
|
||||
id: "attractions",
|
||||
label: "Достопримечательности",
|
||||
icon: Landmark,
|
||||
path: "/sight",
|
||||
requiredRoles: ["sights_ro", "sights_rw"],
|
||||
},
|
||||
{
|
||||
id: "stations",
|
||||
label: "Остановки",
|
||||
icon: PersonStanding,
|
||||
path: "/station",
|
||||
requiredRoles: ["stations_ro", "stations_rw"],
|
||||
},
|
||||
{
|
||||
id: "routes",
|
||||
label: "Маршруты",
|
||||
icon: Split,
|
||||
path: "/route",
|
||||
requiredRoles: ["routes_ro", "routes_rw"],
|
||||
},
|
||||
|
||||
{
|
||||
@@ -110,27 +146,36 @@ export const NAVIGATION_ITEMS: {
|
||||
label: "Страны",
|
||||
icon: Earth,
|
||||
path: "/country",
|
||||
for_admin: true,
|
||||
requiredRoles: ["countries_ro", "countries_rw"],
|
||||
},
|
||||
{
|
||||
id: "cities",
|
||||
label: "Города",
|
||||
icon: Building2,
|
||||
path: "/city",
|
||||
for_admin: true,
|
||||
requiredRoles: ["cities_ro", "cities_rw"],
|
||||
},
|
||||
{
|
||||
id: "carriers",
|
||||
label: "Перевозчики",
|
||||
// @ts-ignore
|
||||
icon: CarrierSvg,
|
||||
icon: () => <img src={carrierIcon} alt="Перевозчики" />,
|
||||
path: "/carrier",
|
||||
for_admin: true,
|
||||
requiredRoles: ["carriers_ro", "carriers_rw"],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
secondary: [
|
||||
{
|
||||
id: "clear-cache",
|
||||
label: "Очистить кэш",
|
||||
icon: RefreshCcw,
|
||||
onClick: () => {
|
||||
snapshotStore.clearStoreCache();
|
||||
toast.success("Кэш очищен");
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "logout",
|
||||
label: "Выйти",
|
||||
@@ -143,7 +188,31 @@ export const NAVIGATION_ITEMS: {
|
||||
],
|
||||
};
|
||||
|
||||
function collectRoles(list: NavigationItem[]): string[] {
|
||||
const roles = new Set<string>(["admin"]);
|
||||
const walk = (items: NavigationItem[]) => {
|
||||
for (const item of items) {
|
||||
item.requiredRoles?.forEach((r) => roles.add(r));
|
||||
item.nestedItems && walk(item.nestedItems);
|
||||
}
|
||||
};
|
||||
walk(list);
|
||||
return Array.from(roles);
|
||||
}
|
||||
|
||||
export const ALL_ROLES = collectRoles(NAVIGATION_ITEMS.primary);
|
||||
|
||||
export const VEHICLE_TYPES = [
|
||||
{ label: "Трамвай", value: 1 },
|
||||
{ label: "Автобус", value: 3 },
|
||||
{ label: "Троллейбус", value: 2 },
|
||||
{ label: "Трамвай", value: 1 },
|
||||
{ label: "Электробус", value: 4 },
|
||||
{ label: "Электричка", value: 5 },
|
||||
{ label: "Вагон метро", value: 6 },
|
||||
{ label: "Вагон ЖД", value: 7 },
|
||||
];
|
||||
|
||||
export const VEHICLE_MODELS = [
|
||||
{ label: "71-431P «Довлатов»", value: "71-431P «Довлатов»" },
|
||||
{ label: "71-638M-02 «Альтаир»", value: "71-638M-02 «Альтаир»" },
|
||||
] as const;
|
||||
|
||||
@@ -1,4 +1,22 @@
|
||||
import * as countries from "i18n-iso-countries";
|
||||
import * as ru from "i18n-iso-countries/langs/ru.json";
|
||||
import * as en from "i18n-iso-countries/langs/en.json";
|
||||
import * as zh from "i18n-iso-countries/langs/zh.json";
|
||||
|
||||
countries.registerLocale(ru);
|
||||
countries.registerLocale(en);
|
||||
countries.registerLocale(zh);
|
||||
|
||||
const generateCountriesList = (locale: string) => {
|
||||
const names = countries.getNames(locale);
|
||||
return Object.entries(names).map(([code, name]) => ({
|
||||
code: code,
|
||||
name: name,
|
||||
}));
|
||||
};
|
||||
|
||||
export const API_URL = import.meta.env.VITE_API_URL;
|
||||
|
||||
export const MEDIA_TYPE_LABELS = {
|
||||
1: "Фото",
|
||||
2: "Видео",
|
||||
@@ -15,6 +33,7 @@ export const MEDIA_TYPE_VALUES = {
|
||||
video: 2,
|
||||
icon: 3,
|
||||
thumbnail: 3,
|
||||
alt_icon: 3,
|
||||
watermark_lu: 4,
|
||||
watermark_rd: 4,
|
||||
panorama: 5,
|
||||
@@ -22,751 +41,6 @@ export const MEDIA_TYPE_VALUES = {
|
||||
video_preview: 2,
|
||||
};
|
||||
|
||||
export const RU_COUNTRIES = [
|
||||
{ code: "AF", name: "Афганистан" },
|
||||
{ code: "AX", name: "Аландские острова" },
|
||||
{ code: "AL", name: "Албания" },
|
||||
{ code: "DZ", name: "Алжир" },
|
||||
{ code: "AS", name: "Американское Самоа" },
|
||||
{ code: "AD", name: "Андорра" },
|
||||
{ code: "AO", name: "Ангола" },
|
||||
{ code: "AI", name: "Ангилья" },
|
||||
{ code: "AQ", name: "Антарктида" },
|
||||
{ code: "AG", name: "Антигуа и Барбуда" },
|
||||
{ code: "AR", name: "Аргентина" },
|
||||
{ code: "AM", name: "Армения" },
|
||||
{ code: "AW", name: "Аруба" },
|
||||
{ code: "AU", name: "Австралия" },
|
||||
{ code: "AT", name: "Австрия" },
|
||||
{ code: "AZ", name: "Азербайджан" },
|
||||
{ code: "BS", name: "Багамы" },
|
||||
{ code: "BH", name: "Бахрейн" },
|
||||
{ code: "BD", name: "Бангладеш" },
|
||||
{ code: "BB", name: "Барбадос" },
|
||||
{ code: "BY", name: "Беларусь" },
|
||||
{ code: "BE", name: "Бельгия" },
|
||||
{ code: "BZ", name: "Белиз" },
|
||||
{ code: "BJ", name: "Бенин" },
|
||||
{ code: "BM", name: "Бермуды" },
|
||||
{ code: "BT", name: "Бутан" },
|
||||
{ code: "BO", name: "Боливия" },
|
||||
{ code: "BA", name: "Босния и Герцеговина" },
|
||||
{ code: "BW", name: "Ботсвана" },
|
||||
{ code: "BV", name: "Остров Буве" },
|
||||
{ code: "BR", name: "Бразилия" },
|
||||
{ code: "IO", name: "Британская территория в Индийском океане" },
|
||||
{ code: "BN", name: "Бруней-Даруссалам" },
|
||||
{ code: "BG", name: "Болгария" },
|
||||
{ code: "BF", name: "Буркина-Фасо" },
|
||||
{ code: "BI", name: "Бурунди" },
|
||||
{ code: "KH", name: "Камбоджа" },
|
||||
{ code: "CM", name: "Камерун" },
|
||||
{ code: "CA", name: "Канада" },
|
||||
{ code: "CV", name: "Кабо-Верде" },
|
||||
{ code: "KY", name: "Каймановы острова" },
|
||||
{ code: "CF", name: "Центральноафриканская Республика" },
|
||||
{ code: "TD", name: "Чад" },
|
||||
{ code: "CL", name: "Чили" },
|
||||
{ code: "CN", name: "Китай" },
|
||||
{ code: "CX", name: "Остров Рождества" },
|
||||
{ code: "CC", name: "Кокосовые (Килинг) острова" },
|
||||
{ code: "CO", name: "Колумбия" },
|
||||
{ code: "KM", name: "Коморы" },
|
||||
{ code: "CG", name: "Конго" },
|
||||
{ code: "CD", name: "Демократическая Республика Конго" },
|
||||
{ code: "CK", name: "Острова Кука" },
|
||||
{ code: "CR", name: "Коста-Рика" },
|
||||
{ code: "CI", name: "Кот-д'Ивуар" },
|
||||
{ code: "HR", name: "Хорватия" },
|
||||
{ code: "CU", name: "Куба" },
|
||||
{ code: "CY", name: "Кипр" },
|
||||
{ code: "CZ", name: "Чехия" },
|
||||
{ code: "DK", name: "Дания" },
|
||||
{ code: "DJ", name: "Джибути" },
|
||||
{ code: "DM", name: "Доминика" },
|
||||
{ code: "DO", name: "Доминиканская Республика" },
|
||||
{ code: "EC", name: "Эквадор" },
|
||||
{ code: "EG", name: "Египет" },
|
||||
{ code: "SV", name: "Сальвадор" },
|
||||
{ code: "GQ", name: "Экваториальная Гвинея" },
|
||||
{ code: "ER", name: "Эритрея" },
|
||||
{ code: "EE", name: "Эстония" },
|
||||
{ code: "ET", name: "Эфиопия" },
|
||||
{ code: "FK", name: "Фолклендские острова (Мальвинские)" },
|
||||
{ code: "FO", name: "Фарерские острова" },
|
||||
{ code: "FJ", name: "Фиджи" },
|
||||
{ code: "FI", name: "Финляндия" },
|
||||
{ code: "FR", name: "Франция" },
|
||||
{ code: "GF", name: "Французская Гвиана" },
|
||||
{ code: "PF", name: "Французская Полинезия" },
|
||||
{ code: "TF", name: "Французские Южные территории" },
|
||||
{ code: "GA", name: "Габон" },
|
||||
{ code: "GM", name: "Гамбия" },
|
||||
{ code: "GE", name: "Грузия" },
|
||||
{ code: "DE", name: "Германия" },
|
||||
{ code: "GH", name: "Гана" },
|
||||
{ code: "GI", name: "Гибралтар" },
|
||||
{ code: "GR", name: "Греция" },
|
||||
{ code: "GL", name: "Гренландия" },
|
||||
{ code: "GD", name: "Гренада" },
|
||||
{ code: "GP", name: "Гваделупа" },
|
||||
{ code: "GU", name: "Гуам" },
|
||||
{ code: "GT", name: "Гватемала" },
|
||||
{ code: "GG", name: "Гернси" },
|
||||
{ code: "GN", name: "Гвинея" },
|
||||
{ code: "GW", name: "Гвинея-Бисау" },
|
||||
{ code: "GY", name: "Гайана" },
|
||||
{ code: "HT", name: "Гаити" },
|
||||
{ code: "HM", name: "Остров Херд и острова Макдональд" },
|
||||
{ code: "VA", name: "Ватикан" },
|
||||
{ code: "HN", name: "Гондурас" },
|
||||
{ code: "HK", name: "Гонконг" },
|
||||
{ code: "HU", name: "Венгрия" },
|
||||
{ code: "IS", name: "Исландия" },
|
||||
{ code: "IN", name: "Индия" },
|
||||
{ code: "ID", name: "Индонезия" },
|
||||
{ code: "IR", name: "Иран" },
|
||||
{ code: "IQ", name: "Ирак" },
|
||||
{ code: "IE", name: "Ирландия" },
|
||||
{ code: "IM", name: "Остров Мэн" },
|
||||
{ code: "IL", name: "Израиль" },
|
||||
{ code: "IT", name: "Италия" },
|
||||
{ code: "JM", name: "Ямайка" },
|
||||
{ code: "JP", name: "Япония" },
|
||||
{ code: "JE", name: "Джерси" },
|
||||
{ code: "JO", name: "Иордания" },
|
||||
{ code: "KZ", name: "Казахстан" },
|
||||
{ code: "KE", name: "Кения" },
|
||||
{ code: "KI", name: "Кирибати" },
|
||||
{ code: "KR", name: "Корея" },
|
||||
{ code: "KP", name: "Северная Корея" },
|
||||
{ code: "KW", name: "Кувейт" },
|
||||
{ code: "KG", name: "Киргизия" },
|
||||
{ code: "LA", name: "Лаос" },
|
||||
{ code: "LV", name: "Латвия" },
|
||||
{ code: "LB", name: "Ливан" },
|
||||
{ code: "LS", name: "Лесото" },
|
||||
{ code: "LR", name: "Либерия" },
|
||||
{ code: "LY", name: "Ливия" },
|
||||
{ code: "LI", name: "Лихтенштейн" },
|
||||
{ code: "LT", name: "Литва" },
|
||||
{ code: "LU", name: "Люксембург" },
|
||||
{ code: "MO", name: "Макао" },
|
||||
{ code: "MK", name: "Северная Македония" },
|
||||
{ code: "MG", name: "Мадагаскар" },
|
||||
{ code: "MW", name: "Малави" },
|
||||
{ code: "MY", name: "Малайзия" },
|
||||
{ code: "MV", name: "Мальдивы" },
|
||||
{ code: "ML", name: "Мали" },
|
||||
{ code: "MT", name: "Мальта" },
|
||||
{ code: "MH", name: "Маршалловы Острова" },
|
||||
{ code: "MQ", name: "Мартиника" },
|
||||
{ code: "MR", name: "Мавритания" },
|
||||
{ code: "MU", name: "Маврикий" },
|
||||
{ code: "YT", name: "Майотта" },
|
||||
{ code: "MX", name: "Мексика" },
|
||||
{ code: "FM", name: "Микронезия" },
|
||||
{ code: "MD", name: "Молдова" },
|
||||
{ code: "MC", name: "Монако" },
|
||||
{ code: "MN", name: "Монголия" },
|
||||
{ code: "ME", name: "Черногория" },
|
||||
{ code: "MS", name: "Монтсеррат" },
|
||||
{ code: "MA", name: "Марокко" },
|
||||
{ code: "MZ", name: "Мозамбик" },
|
||||
{ code: "MM", name: "Мьянма" },
|
||||
{ code: "NA", name: "Намибия" },
|
||||
{ code: "NR", name: "Науру" },
|
||||
{ code: "NP", name: "Непал" },
|
||||
{ code: "NL", name: "Нидерланды" },
|
||||
{ code: "AN", name: "Нидерландские Антильские острова" },
|
||||
{ code: "NC", name: "Новая Каледония" },
|
||||
{ code: "NZ", name: "Новая Зеландия" },
|
||||
{ code: "NI", name: "Никарагуа" },
|
||||
{ code: "NE", name: "Нигер" },
|
||||
{ code: "NG", name: "Нигерия" },
|
||||
{ code: "NU", name: "Ниуэ" },
|
||||
{ code: "NF", name: "Остров Норфолк" },
|
||||
{ code: "MP", name: "Северные Марианские острова" },
|
||||
{ code: "NO", name: "Норвегия" },
|
||||
{ code: "OM", name: "Оман" },
|
||||
{ code: "PK", name: "Пакистан" },
|
||||
{ code: "PW", name: "Палау" },
|
||||
{ code: "PS", name: "Палестинская территория" },
|
||||
{ code: "PA", name: "Панама" },
|
||||
{ code: "PG", name: "Папуа — Новая Гвинея" },
|
||||
{ code: "PY", name: "Парагвай" },
|
||||
{ code: "PE", name: "Перу" },
|
||||
{ code: "PH", name: "Филиппины" },
|
||||
{ code: "PN", name: "Питкэрн" },
|
||||
{ code: "PL", name: "Польша" },
|
||||
{ code: "PT", name: "Португалия" },
|
||||
{ code: "PR", name: "Пуэрто-Рико" },
|
||||
{ code: "QA", name: "Катар" },
|
||||
{ code: "RE", name: "Реюньон" },
|
||||
{ code: "RO", name: "Румыния" },
|
||||
{ code: "RU", name: "Россия" },
|
||||
{ code: "RW", name: "Руанда" },
|
||||
{ code: "BL", name: "Сен-Бартелеми" },
|
||||
{ code: "SH", name: "Остров Святой Елены" },
|
||||
{ code: "KN", name: "Сент-Китс и Невис" },
|
||||
{ code: "LC", name: "Сент-Люсия" },
|
||||
{ code: "MF", name: "Сен-Мартен" },
|
||||
{ code: "PM", name: "Сен-Пьер и Микелон" },
|
||||
{ code: "VC", name: "Сент-Винсент и Гренадины" },
|
||||
{ code: "WS", name: "Самоа" },
|
||||
{ code: "SM", name: "Сан-Марино" },
|
||||
{ code: "ST", name: "Сан-Томе и Принсипи" },
|
||||
{ code: "SA", name: "Саудовская Аравия" },
|
||||
{ code: "SN", name: "Сенегал" },
|
||||
{ code: "RS", name: "Сербия" },
|
||||
{ code: "SC", name: "Сейшельские Острова" },
|
||||
{ code: "SL", name: "Сьерра-Леоне" },
|
||||
{ code: "SG", name: "Сингапур" },
|
||||
{ code: "SK", name: "Словакия" },
|
||||
{ code: "SI", name: "Словения" },
|
||||
{ code: "SB", name: "Соломоновы Острова" },
|
||||
{ code: "SO", name: "Сомали" },
|
||||
{ code: "ZA", name: "Южная Африка" },
|
||||
{ code: "GS", name: "Южная Георгия и Южные Сандвичевы острова" },
|
||||
{ code: "ES", name: "Испания" },
|
||||
{ code: "LK", name: "Шри-Ланка" },
|
||||
{ code: "SD", name: "Судан" },
|
||||
{ code: "SR", name: "Суринам" },
|
||||
{ code: "SJ", name: "Шпицберген и Ян-Майен" },
|
||||
{ code: "SZ", name: "Свазиленд" },
|
||||
{ code: "SE", name: "Швеция" },
|
||||
{ code: "CH", name: "Швейцария" },
|
||||
{ code: "SY", name: "Сирия" },
|
||||
{ code: "TW", name: "Тайвань" },
|
||||
{ code: "TJ", name: "Таджикистан" },
|
||||
{ code: "TZ", name: "Танзания" },
|
||||
{ code: "TH", name: "Таиланд" },
|
||||
{ code: "TL", name: "Восточный Тимор" },
|
||||
{ code: "TG", name: "Того" },
|
||||
{ code: "TK", name: "Токелау" },
|
||||
{ code: "TO", name: "Тонга" },
|
||||
{ code: "TT", name: "Тринидад и Тобаго" },
|
||||
{ code: "TN", name: "Тунис" },
|
||||
{ code: "TR", name: "Турция" },
|
||||
{ code: "TM", name: "Туркмения" },
|
||||
{ code: "TC", name: "Теркс и Кайкос" },
|
||||
{ code: "TV", name: "Тувалу" },
|
||||
{ code: "UG", name: "Уганда" },
|
||||
{ code: "UA", name: "Украина" },
|
||||
{ code: "AE", name: "Объединённые Арабские Эмираты" },
|
||||
{ code: "GB", name: "Великобритания" },
|
||||
{ code: "US", name: "США" },
|
||||
{ code: "UM", name: "Внешние малые острова США" },
|
||||
{ code: "UY", name: "Уругвай" },
|
||||
{ code: "UZ", name: "Узбекистан" },
|
||||
{ code: "VU", name: "Вануату" },
|
||||
{ code: "VE", name: "Венесуэла" },
|
||||
{ code: "VN", name: "Вьетнам" },
|
||||
{ code: "VG", name: "Британские Виргинские острова" },
|
||||
{ code: "VI", name: "Виргинские острова (США)" },
|
||||
{ code: "WF", name: "Уоллис и Футуна" },
|
||||
{ code: "EH", name: "Западная Сахара" },
|
||||
{ code: "YE", name: "Йемен" },
|
||||
{ code: "ZM", name: "Замбия" },
|
||||
{ code: "ZW", name: "Зимбабве" },
|
||||
];
|
||||
|
||||
// countries-en.js
|
||||
export const EN_COUNTRIES = [
|
||||
{ code: "AF", name: "Afghanistan" },
|
||||
{ code: "AX", name: "Aland Islands" },
|
||||
{ code: "AL", name: "Albania" },
|
||||
{ code: "DZ", name: "Algeria" },
|
||||
{ code: "AS", name: "American Samoa" },
|
||||
{ code: "AD", name: "Andorra" },
|
||||
{ code: "AO", name: "Angola" },
|
||||
{ code: "AI", name: "Anguilla" },
|
||||
{ code: "AQ", name: "Antarctica" },
|
||||
{ code: "AG", name: "Antigua And Barbuda" },
|
||||
{ code: "AR", name: "Argentina" },
|
||||
{ code: "AM", name: "Armenia" },
|
||||
{ code: "AW", name: "Aruba" },
|
||||
{ code: "AU", name: "Australia" },
|
||||
{ code: "AT", name: "Austria" },
|
||||
{ code: "AZ", name: "Azerbaijan" },
|
||||
{ code: "BS", name: "Bahamas" },
|
||||
{ code: "BH", name: "Bahrain" },
|
||||
{ code: "BD", name: "Bangladesh" },
|
||||
{ code: "BB", name: "Barbados" },
|
||||
{ code: "BY", name: "Belarus" },
|
||||
{ code: "BE", name: "Belgium" },
|
||||
{ code: "BZ", name: "Belize" },
|
||||
{ code: "BJ", name: "Benin" },
|
||||
{ code: "BM", name: "Bermuda" },
|
||||
{ code: "BT", name: "Bhutan" },
|
||||
{ code: "BO", name: "Bolivia" },
|
||||
{ code: "BA", name: "Bosnia And Herzegovina" },
|
||||
{ code: "BW", name: "Botswana" },
|
||||
{ code: "BV", name: "Bouvet Island" },
|
||||
{ code: "BR", name: "Brazil" },
|
||||
{ code: "IO", name: "British Indian Ocean Territory" },
|
||||
{ code: "BN", name: "Brunei Darussalam" },
|
||||
{ code: "BG", name: "Bulgaria" },
|
||||
{ code: "BF", name: "Burkina Faso" },
|
||||
{ code: "BI", name: "Burundi" },
|
||||
{ code: "KH", name: "Cambodia" },
|
||||
{ code: "CM", name: "Cameroon" },
|
||||
{ code: "CA", name: "Canada" },
|
||||
{ code: "CV", name: "Cape Verde" },
|
||||
{ code: "KY", name: "Cayman Islands" },
|
||||
{ code: "CF", name: "Central African Republic" },
|
||||
{ code: "TD", name: "Chad" },
|
||||
{ code: "CL", name: "Chile" },
|
||||
{ code: "CN", name: "China" },
|
||||
{ code: "CX", name: "Christmas Island" },
|
||||
{ code: "CC", name: "Cocos (Keeling) Islands" },
|
||||
{ code: "CO", name: "Colombia" },
|
||||
{ code: "KM", name: "Comoros" },
|
||||
{ code: "CG", name: "Congo" },
|
||||
{ code: "CD", name: "Congo, Democratic Republic" },
|
||||
{ code: "CK", name: "Cook Islands" },
|
||||
{ code: "CR", name: "Costa Rica" },
|
||||
{ code: "CI", name: "Cote D'Ivoire" },
|
||||
{ code: "HR", name: "Croatia" },
|
||||
{ code: "CU", name: "Cuba" },
|
||||
{ code: "CY", name: "Cyprus" },
|
||||
{ code: "CZ", name: "Czech Republic" },
|
||||
{ code: "DK", name: "Denmark" },
|
||||
{ code: "DJ", name: "Djibouti" },
|
||||
{ code: "DM", name: "Dominica" },
|
||||
{ code: "DO", name: "Dominican Republic" },
|
||||
{ code: "EC", name: "Ecuador" },
|
||||
{ code: "EG", name: "Egypt" },
|
||||
{ code: "SV", name: "El Salvador" },
|
||||
{ code: "GQ", name: "Equatorial Guinea" },
|
||||
{ code: "ER", name: "Eritrea" },
|
||||
{ code: "EE", name: "Estonia" },
|
||||
{ code: "ET", name: "Ethiopia" },
|
||||
{ code: "FK", name: "Falkland Islands (Malvinas)" },
|
||||
{ code: "FO", name: "Faroe Islands" },
|
||||
{ code: "FJ", name: "Fiji" },
|
||||
{ code: "FI", name: "Finland" },
|
||||
{ code: "FR", name: "France" },
|
||||
{ code: "GF", name: "French Guiana" },
|
||||
{ code: "PF", name: "French Polynesia" },
|
||||
{ code: "TF", name: "French Southern Territories" },
|
||||
{ code: "GA", name: "Gabon" },
|
||||
{ code: "GM", name: "Gambia" },
|
||||
{ code: "GE", name: "Georgia" },
|
||||
{ code: "DE", name: "Germany" },
|
||||
{ code: "GH", name: "Ghana" },
|
||||
{ code: "GI", name: "Gibraltar" },
|
||||
{ code: "GR", name: "Greece" },
|
||||
{ code: "GL", name: "Greenland" },
|
||||
{ code: "GD", name: "Grenada" },
|
||||
{ code: "GP", name: "Guadeloupe" },
|
||||
{ code: "GU", name: "Guam" },
|
||||
{ code: "GT", name: "Guatemala" },
|
||||
{ code: "GG", name: "Guernsey" },
|
||||
{ code: "GN", name: "Guinea" },
|
||||
{ code: "GW", name: "Guinea-Bissau" },
|
||||
{ code: "GY", name: "Guyana" },
|
||||
{ code: "HT", name: "Haiti" },
|
||||
{ code: "HM", name: "Heard Island & Mcdonald Islands" },
|
||||
{ code: "VA", name: "Holy See (Vatican City State)" },
|
||||
{ code: "HN", name: "Honduras" },
|
||||
{ code: "HK", name: "Hong Kong" },
|
||||
{ code: "HU", name: "Hungary" },
|
||||
{ code: "IS", name: "Iceland" },
|
||||
{ code: "IN", name: "India" },
|
||||
{ code: "ID", name: "Indonesia" },
|
||||
{ code: "IR", name: "Iran, Islamic Republic Of" },
|
||||
{ code: "IQ", name: "Iraq" },
|
||||
{ code: "IE", name: "Ireland" },
|
||||
{ code: "IM", name: "Isle Of Man" },
|
||||
{ code: "IL", name: "Israel" },
|
||||
{ code: "IT", name: "Italy" },
|
||||
{ code: "JM", name: "Jamaica" },
|
||||
{ code: "JP", name: "Japan" },
|
||||
{ code: "JE", name: "Jersey" },
|
||||
{ code: "JO", name: "Jordan" },
|
||||
{ code: "KZ", name: "Kazakhstan" },
|
||||
{ code: "KE", name: "Kenya" },
|
||||
{ code: "KI", name: "Kiribati" },
|
||||
{ code: "KR", name: "Korea" },
|
||||
{ code: "KP", name: "North Korea" },
|
||||
{ code: "KW", name: "Kuwait" },
|
||||
{ code: "KG", name: "Kyrgyzstan" },
|
||||
{ code: "LA", name: "Lao People's Democratic Republic" },
|
||||
{ code: "LV", name: "Latvia" },
|
||||
{ code: "LB", name: "Lebanon" },
|
||||
{ code: "LS", name: "Lesotho" },
|
||||
{ code: "LR", name: "Liberia" },
|
||||
{ code: "LY", name: "Libyan Arab Jamahiriya" },
|
||||
{ code: "LI", name: "Liechtenstein" },
|
||||
{ code: "LT", name: "Lithuania" },
|
||||
{ code: "LU", name: "Luxembourg" },
|
||||
{ code: "MO", name: "Macao" },
|
||||
{ code: "MK", name: "Macedonia" },
|
||||
{ code: "MG", name: "Madagascar" },
|
||||
{ code: "MW", name: "Malawi" },
|
||||
{ code: "MY", name: "Malaysia" },
|
||||
{ code: "MV", name: "Maldives" },
|
||||
{ code: "ML", name: "Mali" },
|
||||
{ code: "MT", name: "Malta" },
|
||||
{ code: "MH", name: "Marshall Islands" },
|
||||
{ code: "MQ", name: "Martinique" },
|
||||
{ code: "MR", name: "Mauritania" },
|
||||
{ code: "MU", name: "Mauritius" },
|
||||
{ code: "YT", name: "Mayotte" },
|
||||
{ code: "MX", name: "Mexico" },
|
||||
{ code: "FM", name: "Micronesia, Federated States Of" },
|
||||
{ code: "MD", name: "Moldova" },
|
||||
{ code: "MC", name: "Monaco" },
|
||||
{ code: "MN", name: "Mongolia" },
|
||||
{ code: "ME", name: "Montenegro" },
|
||||
{ code: "MS", name: "Montserrat" },
|
||||
{ code: "MA", name: "Morocco" },
|
||||
{ code: "MZ", name: "Mozambique" },
|
||||
{ code: "MM", name: "Myanmar" },
|
||||
{ code: "NA", name: "Namibia" },
|
||||
{ code: "NR", name: "Nauru" },
|
||||
{ code: "NP", name: "Nepal" },
|
||||
{ code: "NL", name: "Netherlands" },
|
||||
{ code: "AN", name: "Netherlands Antilles" },
|
||||
{ code: "NC", name: "New Caledonia" },
|
||||
{ code: "NZ", name: "New Zealand" },
|
||||
{ code: "NI", name: "Nicaragua" },
|
||||
{ code: "NE", name: "Niger" },
|
||||
{ code: "NG", name: "Nigeria" },
|
||||
{ code: "NU", name: "Niue" },
|
||||
{ code: "NF", name: "Norfolk Island" },
|
||||
{ code: "MP", name: "Northern Mariana Islands" },
|
||||
{ code: "NO", name: "Norway" },
|
||||
{ code: "OM", name: "Oman" },
|
||||
{ code: "PK", name: "Pakistan" },
|
||||
{ code: "PW", name: "Palau" },
|
||||
{ code: "PS", name: "Palestinian Territory, Occupied" },
|
||||
{ code: "PA", name: "Panama" },
|
||||
{ code: "PG", name: "Papua New Guinea" },
|
||||
{ code: "PY", name: "Paraguay" },
|
||||
{ code: "PE", name: "Peru" },
|
||||
{ code: "PH", name: "Philippines" },
|
||||
{ code: "PN", name: "Pitcairn" },
|
||||
{ code: "PL", name: "Poland" },
|
||||
{ code: "PT", name: "Portugal" },
|
||||
{ code: "PR", name: "Puerto Rico" },
|
||||
{ code: "QA", name: "Qatar" },
|
||||
{ code: "RE", name: "Reunion" },
|
||||
{ code: "RO", name: "Romania" },
|
||||
{ code: "RU", name: "Russian Federation" },
|
||||
{ code: "RW", name: "Rwanda" },
|
||||
{ code: "BL", name: "Saint Barthelemy" },
|
||||
{ code: "SH", name: "Saint Helena" },
|
||||
{ code: "KN", name: "Saint Kitts And Nevis" },
|
||||
{ code: "LC", name: "Saint Lucia" },
|
||||
{ code: "MF", name: "Saint Martin" },
|
||||
{ code: "PM", name: "Saint Pierre And Miquelon" },
|
||||
{ code: "VC", name: "Saint Vincent And Grenadines" },
|
||||
{ code: "WS", name: "Samoa" },
|
||||
{ code: "SM", name: "San Marino" },
|
||||
{ code: "ST", name: "Sao Tome And Principe" },
|
||||
{ code: "SA", name: "Saudi Arabia" },
|
||||
{ code: "SN", name: "Senegal" },
|
||||
{ code: "RS", name: "Serbia" },
|
||||
{ code: "SC", name: "Seychelles" },
|
||||
{ code: "SL", name: "Sierra Leone" },
|
||||
{ code: "SG", name: "Singapore" },
|
||||
{ code: "SK", name: "Slovakia" },
|
||||
{ code: "SI", name: "Slovenia" },
|
||||
{ code: "SB", name: "Solomon Islands" },
|
||||
{ code: "SO", name: "Somalia" },
|
||||
{ code: "ZA", name: "South Africa" },
|
||||
{ code: "GS", name: "South Georgia And Sandwich Isl." },
|
||||
{ code: "ES", name: "Spain" },
|
||||
{ code: "LK", name: "Sri Lanka" },
|
||||
{ code: "SD", name: "Sudan" },
|
||||
{ code: "SR", name: "Suriname" },
|
||||
{ code: "SJ", name: "Svalbard And Jan Mayen" },
|
||||
{ code: "SZ", name: "Swaziland" },
|
||||
{ code: "SE", name: "Sweden" },
|
||||
{ code: "CH", name: "Switzerland" },
|
||||
{ code: "SY", name: "Syrian Arab Republic" },
|
||||
{ code: "TW", name: "Taiwan" },
|
||||
{ code: "TJ", name: "Tajikistan" },
|
||||
{ code: "TZ", name: "Tanzania" },
|
||||
{ code: "TH", name: "Thailand" },
|
||||
{ code: "TL", name: "Timor-Leste" },
|
||||
{ code: "TG", name: "Togo" },
|
||||
{ code: "TK", name: "Tokelau" },
|
||||
{ code: "TO", name: "Tonga" },
|
||||
{ code: "TT", name: "Trinidad And Tobago" },
|
||||
{ code: "TN", name: "Tunisia" },
|
||||
{ code: "TR", name: "Turkey" },
|
||||
{ code: "TM", name: "Turkmenistan" },
|
||||
{ code: "TC", name: "Turks And Caicos Islands" },
|
||||
{ code: "TV", name: "Tuvalu" },
|
||||
{ code: "UG", name: "Uganda" },
|
||||
{ code: "UA", name: "Ukraine" },
|
||||
{ code: "AE", name: "United Arab Emirates" },
|
||||
{ code: "GB", name: "United Kingdom" },
|
||||
{ code: "US", name: "United States" },
|
||||
{ code: "UM", name: "United States Outlying Islands" },
|
||||
{ code: "UY", name: "Uruguay" },
|
||||
{ code: "UZ", name: "Uzbekistan" },
|
||||
{ code: "VU", name: "Vanuatu" },
|
||||
{ code: "VE", name: "Venezuela" },
|
||||
{ code: "VN", name: "Vietnam" },
|
||||
{ code: "VG", name: "Virgin Islands, British" },
|
||||
{ code: "VI", name: "Virgin Islands, U.S." },
|
||||
{ code: "WF", name: "Wallis And Futuna" },
|
||||
{ code: "EH", name: "Western Sahara" },
|
||||
{ code: "YE", name: "Yemen" },
|
||||
{ code: "ZM", name: "Zambia" },
|
||||
{ code: "ZW", name: "Zimbabwe" },
|
||||
];
|
||||
|
||||
// countries-zh.js
|
||||
export const ZH_COUNTRIES = [
|
||||
{ code: "AF", name: "阿富汗" },
|
||||
{ code: "AX", name: "奥兰群岛" },
|
||||
{ code: "AL", name: "阿尔巴尼亚" },
|
||||
{ code: "DZ", name: "阿尔及利亚" },
|
||||
{ code: "AS", name: "美属萨摩亚" },
|
||||
{ code: "AD", name: "安道尔" },
|
||||
{ code: "AO", name: "安哥拉" },
|
||||
{ code: "AI", name: "安圭拉" },
|
||||
{ code: "AQ", name: "南极洲" },
|
||||
{ code: "AG", name: "安提瓜和巴布达" },
|
||||
{ code: "AR", name: "阿根廷" },
|
||||
{ code: "AM", name: "亚美尼亚" },
|
||||
{ code: "AW", name: "阿鲁巴" },
|
||||
{ code: "AU", name: "澳大利亚" },
|
||||
{ code: "AT", name: "奥地利" },
|
||||
{ code: "AZ", name: "阿塞拜疆" },
|
||||
{ code: "BS", name: "巴哈马" },
|
||||
{ code: "BH", name: "巴林" },
|
||||
{ code: "BD", name: "孟加拉国" },
|
||||
{ code: "BB", name: "巴巴多斯" },
|
||||
{ code: "BY", name: "白俄罗斯" },
|
||||
{ code: "BE", name: "比利时" },
|
||||
{ code: "BZ", name: "伯利兹" },
|
||||
{ code: "BJ", name: "贝宁" },
|
||||
{ code: "BM", name: "百慕大" },
|
||||
{ code: "BT", name: "不丹" },
|
||||
{ code: "BO", name: "玻利维亚" },
|
||||
{ code: "BA", name: "波斯尼亚和黑塞哥维那" },
|
||||
{ code: "BW", name: "博茨瓦纳" },
|
||||
{ code: "BV", name: "布韦岛" },
|
||||
{ code: "BR", name: "巴西" },
|
||||
{ code: "IO", name: "英属印度洋领地" },
|
||||
{ code: "BN", name: "文莱" },
|
||||
{ code: "BG", name: "保加利亚" },
|
||||
{ code: "BF", name: "布基纳法索" },
|
||||
{ code: "BI", name: "布隆迪" },
|
||||
{ code: "KH", name: "柬埔寨" },
|
||||
{ code: "CM", name: "喀麦隆" },
|
||||
{ code: "CA", name: "加拿大" },
|
||||
{ code: "CV", name: "佛得角" },
|
||||
{ code: "KY", name: "开曼群岛" },
|
||||
{ code: "CF", name: "中非共和国" },
|
||||
{ code: "TD", name: "乍得" },
|
||||
{ code: "CL", name: "智利" },
|
||||
{ code: "CN", name: "中国" },
|
||||
{ code: "CX", name: "圣诞岛" },
|
||||
{ code: "CC", name: "科科斯(基林)群岛" },
|
||||
{ code: "CO", name: "哥伦比亚" },
|
||||
{ code: "KM", name: "科摩罗" },
|
||||
{ code: "CG", name: "刚果" },
|
||||
{ code: "CD", name: "刚果(金)" },
|
||||
{ code: "CK", name: "库克群岛" },
|
||||
{ code: "CR", name: "哥斯达黎加" },
|
||||
{ code: "CI", name: "科特迪瓦" },
|
||||
{ code: "HR", name: "克罗地亚" },
|
||||
{ code: "CU", name: "古巴" },
|
||||
{ code: "CY", name: "塞浦路斯" },
|
||||
{ code: "CZ", name: "捷克" },
|
||||
{ code: "DK", name: "丹麦" },
|
||||
{ code: "DJ", name: "吉布提" },
|
||||
{ code: "DM", name: "多米尼克" },
|
||||
{ code: "DO", name: "多米尼加共和国" },
|
||||
{ code: "EC", name: "厄瓜多尔" },
|
||||
{ code: "EG", name: "埃及" },
|
||||
{ code: "SV", name: "萨尔瓦多" },
|
||||
{ code: "GQ", name: "赤道几内亚" },
|
||||
{ code: "ER", name: "厄立特里亚" },
|
||||
{ code: "EE", name: "爱沙尼亚" },
|
||||
{ code: "ET", name: "埃塞俄比亚" },
|
||||
{ code: "FK", name: "福克兰群岛" },
|
||||
{ code: "FO", name: "法罗群岛" },
|
||||
{ code: "FJ", name: "斐济" },
|
||||
{ code: "FI", name: "芬兰" },
|
||||
{ code: "FR", name: "法国" },
|
||||
{ code: "GF", name: "法属圭亚那" },
|
||||
{ code: "PF", name: "法属波利尼西亚" },
|
||||
{ code: "TF", name: "法属南部领地" },
|
||||
{ code: "GA", name: "加蓬" },
|
||||
{ code: "GM", name: "冈比亚" },
|
||||
{ code: "GE", name: "格鲁吉亚" },
|
||||
{ code: "DE", name: "德国" },
|
||||
{ code: "GH", name: "加纳" },
|
||||
{ code: "GI", name: "直布罗陀" },
|
||||
{ code: "GR", name: "希腊" },
|
||||
{ code: "GL", name: "格陵兰" },
|
||||
{ code: "GD", name: "格林纳达" },
|
||||
{ code: "GP", name: "瓜德罗普" },
|
||||
{ code: "GU", name: "关岛" },
|
||||
{ code: "GT", name: "危地马拉" },
|
||||
{ code: "GG", name: "根西岛" },
|
||||
{ code: "GN", name: "几内亚" },
|
||||
{ code: "GW", name: "几内亚比绍" },
|
||||
{ code: "GY", name: "圭亚那" },
|
||||
{ code: "HT", name: "海地" },
|
||||
{ code: "HM", name: "赫德岛和麦克唐纳群岛" },
|
||||
{ code: "VA", name: "梵蒂冈" },
|
||||
{ code: "HN", name: "洪都拉斯" },
|
||||
{ code: "HK", name: "中国香港" },
|
||||
{ code: "HU", name: "匈牙利" },
|
||||
{ code: "IS", name: "冰岛" },
|
||||
{ code: "IN", name: "印度" },
|
||||
{ code: "ID", name: "印度尼西亚" },
|
||||
{ code: "IR", name: "伊朗" },
|
||||
{ code: "IQ", name: "伊拉克" },
|
||||
{ code: "IE", name: "爱尔兰" },
|
||||
{ code: "IM", name: "马恩岛" },
|
||||
{ code: "IL", name: "以色列" },
|
||||
{ code: "IT", name: "意大利" },
|
||||
{ code: "JM", name: "牙买加" },
|
||||
{ code: "JP", name: "日本" },
|
||||
{ code: "JE", name: "泽西岛" },
|
||||
{ code: "JO", name: "约旦" },
|
||||
{ code: "KZ", name: "哈萨克斯坦" },
|
||||
{ code: "KE", name: "肯尼亚" },
|
||||
{ code: "KI", name: "基里巴斯" },
|
||||
{ code: "KR", name: "韩国" },
|
||||
{ code: "KP", name: "朝鲜" },
|
||||
{ code: "KW", name: "科威特" },
|
||||
{ code: "KG", name: "吉尔吉斯斯坦" },
|
||||
{ code: "LA", name: "老挝" },
|
||||
{ code: "LV", name: "拉脱维亚" },
|
||||
{ code: "LB", name: "黎巴嫩" },
|
||||
{ code: "LS", name: "莱索托" },
|
||||
{ code: "LR", name: "利比里亚" },
|
||||
{ code: "LY", name: "利比亚" },
|
||||
{ code: "LI", name: "列支敦士登" },
|
||||
{ code: "LT", name: "立陶宛" },
|
||||
{ code: "LU", name: "卢森堡" },
|
||||
{ code: "MO", name: "中国澳门" },
|
||||
{ code: "MK", name: "北马其顿" },
|
||||
{ code: "MG", name: "马达加斯加" },
|
||||
{ code: "MW", name: "马拉维" },
|
||||
{ code: "MY", name: "马来西亚" },
|
||||
{ code: "MV", name: "马尔代夫" },
|
||||
{ code: "ML", name: "马里" },
|
||||
{ code: "MT", name: "马耳他" },
|
||||
{ code: "MH", name: "马绍尔群岛" },
|
||||
{ code: "MQ", name: "马提尼克" },
|
||||
{ code: "MR", name: "毛里塔尼亚" },
|
||||
{ code: "MU", name: "毛里求斯" },
|
||||
{ code: "YT", name: "马约特" },
|
||||
{ code: "MX", name: "墨西哥" },
|
||||
{ code: "FM", name: "密克罗尼西亚" },
|
||||
{ code: "MD", name: "摩尔多瓦" },
|
||||
{ code: "MC", name: "摩纳哥" },
|
||||
{ code: "MN", name: "蒙古" },
|
||||
{ code: "ME", name: "黑山" },
|
||||
{ code: "MS", name: "蒙特塞拉特" },
|
||||
{ code: "MA", name: "摩洛哥" },
|
||||
{ code: "MZ", name: "莫桑比克" },
|
||||
{ code: "MM", name: "缅甸" },
|
||||
{ code: "NA", name: "纳米比亚" },
|
||||
{ code: "NR", name: "瑙鲁" },
|
||||
{ code: "NP", name: "尼泊尔" },
|
||||
{ code: "NL", name: "荷兰" },
|
||||
{ code: "AN", name: "荷属安的列斯" },
|
||||
{ code: "NC", name: "新喀里多尼亚" },
|
||||
{ code: "NZ", name: "新西兰" },
|
||||
{ code: "NI", name: "尼加拉瓜" },
|
||||
{ code: "NE", name: "尼日尔" },
|
||||
{ code: "NG", name: "尼日利亚" },
|
||||
{ code: "NU", name: "纽埃" },
|
||||
{ code: "NF", name: "诺福克岛" },
|
||||
{ code: "MP", name: "北马里亚纳群岛" },
|
||||
{ code: "NO", name: "挪威" },
|
||||
{ code: "OM", name: "阿曼" },
|
||||
{ code: "PK", name: "巴基斯坦" },
|
||||
{ code: "PW", name: "帕劳" },
|
||||
{ code: "PS", name: "巴勒斯坦" },
|
||||
{ code: "PA", name: "巴拿马" },
|
||||
{ code: "PG", name: "巴布亚新几内亚" },
|
||||
{ code: "PY", name: "巴拉圭" },
|
||||
{ code: "PE", name: "秘鲁" },
|
||||
{ code: "PH", name: "菲律宾" },
|
||||
{ code: "PN", name: "皮特凯恩群岛" },
|
||||
{ code: "PL", name: "波兰" },
|
||||
{ code: "PT", name: "葡萄牙" },
|
||||
{ code: "PR", name: "波多黎各" },
|
||||
{ code: "QA", name: "卡塔尔" },
|
||||
{ code: "RE", name: "留尼汪" },
|
||||
{ code: "RO", name: "罗马尼亚" },
|
||||
{ code: "RU", name: "俄罗斯" },
|
||||
{ code: "RW", name: "卢旺达" },
|
||||
{ code: "BL", name: "圣巴泰勒米" },
|
||||
{ code: "SH", name: "圣赫勒拿" },
|
||||
{ code: "KN", name: "圣基茨和尼维斯" },
|
||||
{ code: "LC", name: "圣卢西亚" },
|
||||
{ code: "MF", name: "法属圣马丁" },
|
||||
{ code: "PM", name: "圣皮埃尔和密克隆" },
|
||||
{ code: "VC", name: "圣文森特和格林纳丁斯" },
|
||||
{ code: "WS", name: "萨摩亚" },
|
||||
{ code: "SM", name: "圣马力诺" },
|
||||
{ code: "ST", name: "圣多美和普林西比" },
|
||||
{ code: "SA", name: "沙特阿拉伯" },
|
||||
{ code: "SN", name: "塞内加尔" },
|
||||
{ code: "RS", name: "塞尔维亚" },
|
||||
{ code: "SC", name: "塞舌尔" },
|
||||
{ code: "SL", name: "塞拉利昂" },
|
||||
{ code: "SG", name: "新加坡" },
|
||||
{ code: "SK", name: "斯洛伐克" },
|
||||
{ code: "SI", name: "斯洛文尼亚" },
|
||||
{ code: "SB", name: "所罗门群岛" },
|
||||
{ code: "SO", name: "索马里" },
|
||||
{ code: "ZA", name: "南非" },
|
||||
{ code: "GS", name: "南乔治亚和南桑威奇群岛" },
|
||||
{ code: "ES", name: "西班牙" },
|
||||
{ code: "LK", name: "斯里兰卡" },
|
||||
{ code: "SD", name: "苏丹" },
|
||||
{ code: "SR", name: "苏里南" },
|
||||
{ code: "SJ", name: "斯瓦尔巴和扬马延" },
|
||||
{ code: "SZ", name: "斯威士兰" },
|
||||
{ code: "SE", name: "瑞典" },
|
||||
{ code: "CH", name: "瑞士" },
|
||||
{ code: "SY", name: "叙利亚" },
|
||||
{ code: "TW", name: "中国台湾" },
|
||||
{ code: "TJ", name: "塔吉克斯坦" },
|
||||
{ code: "TZ", name: "坦桑尼亚" },
|
||||
{ code: "TH", name: "泰国" },
|
||||
{ code: "TL", name: "东帝汶" },
|
||||
{ code: "TG", name: "多哥" },
|
||||
{ code: "TK", name: "托克劳" },
|
||||
{ code: "TO", name: "汤加" },
|
||||
{ code: "TT", name: "特立尼达和多巴哥" },
|
||||
{ code: "TN", name: "突尼斯" },
|
||||
{ code: "TR", name: "土耳其" },
|
||||
{ code: "TM", name: "土库曼斯坦" },
|
||||
{ code: "TC", name: "特克斯和凯科斯群岛" },
|
||||
{ code: "TV", name: "图瓦卢" },
|
||||
{ code: "UG", name: "乌干达" },
|
||||
{ code: "UA", name: "乌克兰" },
|
||||
{ code: "AE", name: "阿联酋" },
|
||||
{ code: "GB", name: "英国" },
|
||||
{ code: "US", name: "美国" },
|
||||
{ code: "UM", name: "美国本土外小岛屿" },
|
||||
{ code: "UY", name: "乌拉圭" },
|
||||
{ code: "UZ", name: "乌兹别克斯坦" },
|
||||
{ code: "VU", name: "瓦努阿图" },
|
||||
{ code: "VE", name: "委内瑞拉" },
|
||||
{ code: "VN", name: "越南" },
|
||||
{ code: "VG", name: "英属维尔京群岛" },
|
||||
{ code: "VI", name: "美属维尔京群岛" },
|
||||
{ code: "WF", name: "瓦利斯和富图纳" },
|
||||
{ code: "EH", name: "西撒哈拉" },
|
||||
{ code: "YE", name: "也门" },
|
||||
{ code: "ZM", name: "赞比亚" },
|
||||
{ code: "ZW", name: "津巴布韦" },
|
||||
];
|
||||
export const RU_COUNTRIES = generateCountriesList("ru");
|
||||
export const EN_COUNTRIES = generateCountriesList("en");
|
||||
export const ZH_COUNTRIES = generateCountriesList("zh");
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
/**
|
||||
* Утилита для управления кешем GLTF и blob URL
|
||||
*/
|
||||
|
||||
// Динамический импорт useGLTF для избежания проблем с SSR
|
||||
let useGLTF: any = null;
|
||||
|
||||
const initializeUseGLTF = async () => {
|
||||
@@ -20,9 +15,6 @@ const initializeUseGLTF = async () => {
|
||||
return useGLTF;
|
||||
};
|
||||
|
||||
/**
|
||||
* Очищает кеш GLTF для конкретного URL
|
||||
*/
|
||||
export const clearGLTFCacheForUrl = async (url: string) => {
|
||||
try {
|
||||
const gltf = await initializeUseGLTF();
|
||||
@@ -32,9 +24,6 @@ export const clearGLTFCacheForUrl = async (url: string) => {
|
||||
} catch (error) {}
|
||||
};
|
||||
|
||||
/**
|
||||
* Очищает весь кеш GLTF
|
||||
*/
|
||||
export const clearAllGLTFCache = async () => {
|
||||
try {
|
||||
const gltf = await initializeUseGLTF();
|
||||
@@ -44,9 +33,6 @@ export const clearAllGLTFCache = async () => {
|
||||
} catch (error) {}
|
||||
};
|
||||
|
||||
/**
|
||||
* Очищает blob URL из памяти браузера
|
||||
*/
|
||||
export const revokeBlobURL = (url: string) => {
|
||||
if (url && url.startsWith("blob:")) {
|
||||
try {
|
||||
@@ -55,27 +41,16 @@ export const revokeBlobURL = (url: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Комплексная очистка: blob URL + кеш GLTF
|
||||
*/
|
||||
export const clearBlobAndGLTFCache = async (url: string) => {
|
||||
// Сначала отзываем blob URL
|
||||
revokeBlobURL(url);
|
||||
|
||||
// Затем очищаем кеш GLTF
|
||||
await clearGLTFCacheForUrl(url);
|
||||
};
|
||||
|
||||
/**
|
||||
* Очистка при смене медиа (для предотвращения конфликтов)
|
||||
*/
|
||||
export const clearMediaTransitionCache = async (
|
||||
previousMediaId: string | number | null,
|
||||
newMediaId: string | number | null,
|
||||
newMediaType?: number
|
||||
) => {
|
||||
console.log(newMediaId, newMediaType);
|
||||
// Если переключаемся с/на 3D модель, очищаем весь кеш
|
||||
if (newMediaType === 6 || previousMediaId) {
|
||||
await clearAllGLTFCache();
|
||||
}
|
||||
|
||||
@@ -1,35 +1,19 @@
|
||||
export * from "./mui/theme";
|
||||
export * from "./DecodeJWT";
|
||||
export * from "./gltfCacheManager";
|
||||
export * from "./permissions";
|
||||
|
||||
/**
|
||||
* Генерирует название медиа по умолчанию в разных форматах
|
||||
*
|
||||
* Примеры использования:
|
||||
* - Для достопримечательности с названием: "Михайловский замок_mikhail-zamok_Фото"
|
||||
* - Для достопримечательности без названия: "Название_mikhail-zamok_Фото"
|
||||
* - Для статьи: "Михайловский замок_mikhail-zamok_Факты" (где "Факты" - название статьи)
|
||||
*
|
||||
* @param objectName - Название объекта (достопримечательности, города и т.д.)
|
||||
* @param fileName - Название файла
|
||||
* @param mediaType - Тип медиа (число) или название статьи
|
||||
* @param isArticle - Флаг, указывающий что медиа добавляется к статье
|
||||
* @returns Строка в нужном формате
|
||||
*/
|
||||
export const generateDefaultMediaName = (
|
||||
objectName: string,
|
||||
fileName: string,
|
||||
mediaType: number | string,
|
||||
isArticle: boolean = false
|
||||
): string => {
|
||||
// Убираем расширение из названия файла
|
||||
const fileNameWithoutExtension = fileName.split(".").slice(0, -1).join(".");
|
||||
|
||||
if (isArticle && typeof mediaType === "string") {
|
||||
// Для статей: "Название достопримечательности_название файла_название статьи"
|
||||
return `${objectName}_${fileNameWithoutExtension}_${mediaType}`;
|
||||
} else if (typeof mediaType === "number") {
|
||||
// Получаем название типа медиа
|
||||
const mediaTypeLabels: Record<number, string> = {
|
||||
1: "Фото",
|
||||
2: "Видео",
|
||||
@@ -42,14 +26,20 @@ export const generateDefaultMediaName = (
|
||||
const mediaTypeLabel = mediaTypeLabels[mediaType] || "Медиа";
|
||||
|
||||
if (objectName && objectName.trim() !== "") {
|
||||
// Если есть название объекта: "Название объекта_название файла_тип медиа"
|
||||
return `${objectName}_${fileNameWithoutExtension}_${mediaTypeLabel}`;
|
||||
} else {
|
||||
// Если нет названия объекта: "Название_название файла_тип медиа"
|
||||
return `Название_${fileNameWithoutExtension}_${mediaTypeLabel}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback
|
||||
return `${objectName || "Название"}_${fileNameWithoutExtension}_Медиа`;
|
||||
};
|
||||
|
||||
/** Медиа-id считается пустым, если строка пустая или состоит только из нулей (с дефисами или без). */
|
||||
export const isMediaIdEmpty = (
|
||||
id: string | null | undefined
|
||||
): boolean => {
|
||||
if (id == null || id === "") return true;
|
||||
const digits = id.replace(/-/g, "");
|
||||
return digits === "" || /^0+$/.test(digits);
|
||||
};
|
||||
|
||||
18
src/shared/lib/permissions/index.ts
Normal file
18
src/shared/lib/permissions/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export const canRead = (roles: string[] | undefined, resource: string): boolean => {
|
||||
if (!roles || roles.length === 0) return false;
|
||||
return (
|
||||
roles.includes("admin") ||
|
||||
roles.includes(`${resource}_ro`) ||
|
||||
roles.includes(`${resource}_rw`)
|
||||
);
|
||||
};
|
||||
|
||||
export const canWrite = (roles: string[] | undefined, resource: string): boolean => {
|
||||
if (!roles || roles.length === 0) return false;
|
||||
return roles.includes("admin") || roles.includes(`${resource}_rw`);
|
||||
};
|
||||
|
||||
export const createPermissions = (roles: string[] | undefined) => ({
|
||||
canRead: (resource: string) => canRead(roles, resource),
|
||||
canWrite: (resource: string) => canWrite(roles, resource),
|
||||
});
|
||||
1073
src/shared/modals/ArticleSelectOrCreateDialog/index.tsx
Normal file
1073
src/shared/modals/ArticleSelectOrCreateDialog/index.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,8 @@
|
||||
import { mediaStore, MEDIA_TYPE_LABELS } from "@shared";
|
||||
import {
|
||||
mediaStore,
|
||||
MEDIA_TYPE_LABELS,
|
||||
PreviewBackgroundToggle,
|
||||
} from "@shared";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
@@ -29,6 +33,8 @@ export const PreviewMediaDialog = observer(
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [isPreviewBackgroundDark, setIsPreviewBackgroundDark] =
|
||||
useState(false);
|
||||
const media = mediaId
|
||||
? mediaStore.media.find((m) => m.id === mediaId)
|
||||
: null;
|
||||
@@ -40,6 +46,7 @@ export const PreviewMediaDialog = observer(
|
||||
if (media) {
|
||||
setMediaName(media.media_name);
|
||||
setMediaFilename(media.filename);
|
||||
setIsPreviewBackgroundDark(false);
|
||||
}
|
||||
}, [media]);
|
||||
|
||||
@@ -141,8 +148,19 @@ export const PreviewMediaDialog = observer(
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
position: "relative",
|
||||
backgroundColor: isPreviewBackgroundDark ? "#000" : "#fff",
|
||||
transition: "background-color 150ms ease",
|
||||
}}
|
||||
>
|
||||
{[1, 3, 4, 5].includes(media.media_type) && (
|
||||
<PreviewBackgroundToggle
|
||||
dark={isPreviewBackgroundDark}
|
||||
onToggle={() =>
|
||||
setIsPreviewBackgroundDark((value) => !value)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<MediaViewer
|
||||
media={{
|
||||
id: mediaId,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { articlesStore, authInstance, languageStore } from "@shared";
|
||||
import { articlesStore, authInstance, languageStore, selectedCityStore } from "@shared";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
@@ -116,12 +116,16 @@ export const SelectArticleModal = observer(
|
||||
}
|
||||
};
|
||||
|
||||
const cityId = selectedCityStore.selectedCityId;
|
||||
|
||||
const filteredArticles = articles[languageStore.language].filter(
|
||||
(article) => !linkedArticleIds.includes(article.id)
|
||||
(article) => {
|
||||
if (linkedArticleIds.includes(article.id)) return false;
|
||||
if (searchQuery && !article.service_name?.toLowerCase().includes(searchQuery.toLowerCase())) return false;
|
||||
if (cityId && article.city_id !== cityId) return false;
|
||||
return true;
|
||||
}
|
||||
);
|
||||
// .filter((article) =>
|
||||
// article.service_name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
// );
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Media, mediaStore } from "@shared";
|
||||
import { Media, mediaStore, selectedCityStore } from "@shared";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
@@ -92,11 +92,17 @@ export const SelectMediaDialog = observer(
|
||||
};
|
||||
}, [hoveredMediaId, onSelectMedia, onClose]); // Dependencies for keyboard listener
|
||||
|
||||
const cityId = selectedCityStore.selectedCityId;
|
||||
|
||||
let filteredMedia = media
|
||||
.filter((mediaItem) => !linkedMediaIds.includes(mediaItem.id)) // Use mediaItem to avoid name collision
|
||||
.filter((mediaItem) => !linkedMediaIds.includes(mediaItem.id))
|
||||
.filter((mediaItem) =>
|
||||
mediaItem.media_name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
)
|
||||
.filter((mediaItem) => {
|
||||
if (!cityId) return true;
|
||||
return mediaItem.city_id === cityId;
|
||||
});
|
||||
|
||||
if (mediaType) {
|
||||
filteredMedia = filteredMedia.filter(
|
||||
|
||||
@@ -4,9 +4,13 @@ import {
|
||||
editSightStore,
|
||||
generateDefaultMediaName,
|
||||
clearBlobAndGLTFCache,
|
||||
authStore,
|
||||
snapshotStore,
|
||||
PreviewBackgroundToggle,
|
||||
} from "@shared";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
@@ -39,6 +43,8 @@ interface UploadMediaDialogProps {
|
||||
afterUploadSight?: (id: string) => void;
|
||||
hardcodeType?:
|
||||
| "thumbnail"
|
||||
| "icon"
|
||||
| "alt_icon"
|
||||
| "watermark_lu"
|
||||
| "watermark_rd"
|
||||
| "image"
|
||||
@@ -51,10 +57,12 @@ interface UploadMediaDialogProps {
|
||||
| "carrier"
|
||||
| "country"
|
||||
| "vehicle"
|
||||
| "station";
|
||||
| "station"
|
||||
| "route"
|
||||
| "user";
|
||||
isArticle?: boolean;
|
||||
articleName?: string;
|
||||
initialFile?: File; // <--- добавлено
|
||||
initialFile?: File;
|
||||
}
|
||||
|
||||
export const UploadMediaDialog = observer(
|
||||
@@ -68,7 +76,7 @@ export const UploadMediaDialog = observer(
|
||||
|
||||
isArticle,
|
||||
articleName,
|
||||
initialFile, // <--- добавлено
|
||||
initialFile,
|
||||
}: UploadMediaDialogProps) => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -83,11 +91,12 @@ export const UploadMediaDialog = observer(
|
||||
[]
|
||||
);
|
||||
const [isPreviewLoaded, setIsPreviewLoaded] = useState(false);
|
||||
const [isPreviewBackgroundDark, setIsPreviewBackgroundDark] =
|
||||
useState(false);
|
||||
const previousMediaUrlRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialFile) {
|
||||
// Очищаем предыдущий blob URL если он существует
|
||||
if (
|
||||
previousMediaUrlRef.current &&
|
||||
previousMediaUrlRef.current.startsWith("blob:")
|
||||
@@ -97,8 +106,26 @@ export const UploadMediaDialog = observer(
|
||||
|
||||
setMediaFile(initialFile);
|
||||
setMediaFilename(initialFile.name);
|
||||
setAvailableMediaTypes([2]);
|
||||
setMediaType(2);
|
||||
|
||||
const extension = initialFile.name.split(".").pop()?.toLowerCase();
|
||||
if (extension) {
|
||||
if (["glb", "gltf"].includes(extension)) {
|
||||
setAvailableMediaTypes([6]);
|
||||
setMediaType(6);
|
||||
}
|
||||
if (
|
||||
["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp"].includes(
|
||||
extension
|
||||
)
|
||||
) {
|
||||
setAvailableMediaTypes([1, 3, 4, 5]);
|
||||
setMediaType(1);
|
||||
} else if (["mp4", "webm", "mov"].includes(extension)) {
|
||||
setAvailableMediaTypes([2]);
|
||||
setMediaType(2);
|
||||
}
|
||||
}
|
||||
|
||||
const newBlobUrl = URL.createObjectURL(initialFile);
|
||||
setMediaUrl(newBlobUrl);
|
||||
previousMediaUrlRef.current = newBlobUrl;
|
||||
@@ -106,7 +133,6 @@ export const UploadMediaDialog = observer(
|
||||
}
|
||||
}, [initialFile]);
|
||||
|
||||
// Очистка blob URL при размонтировании компонента
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (
|
||||
@@ -116,13 +142,13 @@ export const UploadMediaDialog = observer(
|
||||
clearBlobAndGLTFCache(previousMediaUrlRef.current);
|
||||
}
|
||||
};
|
||||
}, []); // Пустой массив зависимостей - выполняется только при размонтировании
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (fileToUpload) {
|
||||
setMediaFile(fileToUpload);
|
||||
setMediaFilename(fileToUpload.name);
|
||||
// Try to determine media type from file extension
|
||||
|
||||
const extension = fileToUpload.name.split(".").pop()?.toLowerCase();
|
||||
if (extension) {
|
||||
if (["glb", "gltf"].includes(extension)) {
|
||||
@@ -134,22 +160,18 @@ export const UploadMediaDialog = observer(
|
||||
extension
|
||||
)
|
||||
) {
|
||||
// Для изображений доступны все типы кроме видео
|
||||
setAvailableMediaTypes([1, 3, 4, 5]); // Фото, Иконка, Водяной знак, Панорама, 3Д-модель
|
||||
setMediaType(1); // По умолчанию Фото
|
||||
setAvailableMediaTypes([1, 3, 4, 5]);
|
||||
setMediaType(1);
|
||||
} else if (["mp4", "webm", "mov"].includes(extension)) {
|
||||
// Для видео только тип Видео
|
||||
setAvailableMediaTypes([2]);
|
||||
setMediaType(2);
|
||||
}
|
||||
}
|
||||
|
||||
// Генерируем название по умолчанию если есть контекст
|
||||
if (fileToUpload.name) {
|
||||
let defaultName = "";
|
||||
|
||||
if (isArticle && articleName && contextObjectName) {
|
||||
// Для статей: "Название достопримечательности_название файла_название статьи"
|
||||
defaultName = generateDefaultMediaName(
|
||||
contextObjectName,
|
||||
fileToUpload.name,
|
||||
@@ -157,10 +179,9 @@ export const UploadMediaDialog = observer(
|
||||
true
|
||||
);
|
||||
} else if (contextObjectName && contextObjectName.trim() !== "") {
|
||||
// Для обычных медиа с названием объекта
|
||||
const currentMediaType = hardcodeType
|
||||
? MEDIA_TYPE_VALUES[hardcodeType]
|
||||
: 1; // По умолчанию фото
|
||||
: 1;
|
||||
defaultName = generateDefaultMediaName(
|
||||
contextObjectName,
|
||||
fileToUpload.name,
|
||||
@@ -168,10 +189,9 @@ export const UploadMediaDialog = observer(
|
||||
false
|
||||
);
|
||||
} else {
|
||||
// Для медиа без названия объекта
|
||||
const currentMediaType = hardcodeType
|
||||
? MEDIA_TYPE_VALUES[hardcodeType]
|
||||
: 1; // По умолчанию фото
|
||||
: 1;
|
||||
defaultName = generateDefaultMediaName(
|
||||
"",
|
||||
fileToUpload.name,
|
||||
@@ -185,13 +205,11 @@ export const UploadMediaDialog = observer(
|
||||
}
|
||||
}, [fileToUpload, contextObjectName, hardcodeType, isArticle, articleName]);
|
||||
|
||||
// Обновляем название при изменении типа медиа
|
||||
useEffect(() => {
|
||||
if (mediaFilename && mediaType > 0) {
|
||||
let defaultName = "";
|
||||
|
||||
if (isArticle && articleName && contextObjectName) {
|
||||
// Для статей: "Название достопримечательности_название файла_название статьи"
|
||||
defaultName = generateDefaultMediaName(
|
||||
contextObjectName,
|
||||
mediaFilename,
|
||||
@@ -199,7 +217,6 @@ export const UploadMediaDialog = observer(
|
||||
true
|
||||
);
|
||||
} else if (contextObjectName && contextObjectName.trim() !== "") {
|
||||
// Для обычных медиа с названием объекта
|
||||
const currentMediaType = hardcodeType
|
||||
? MEDIA_TYPE_VALUES[hardcodeType]
|
||||
: mediaType;
|
||||
@@ -210,7 +227,6 @@ export const UploadMediaDialog = observer(
|
||||
false
|
||||
);
|
||||
} else {
|
||||
// Для медиа без названия объекта
|
||||
const currentMediaType = hardcodeType
|
||||
? MEDIA_TYPE_VALUES[hardcodeType]
|
||||
: mediaType;
|
||||
@@ -235,7 +251,6 @@ export const UploadMediaDialog = observer(
|
||||
|
||||
useEffect(() => {
|
||||
if (mediaFile) {
|
||||
// Очищаем предыдущий blob URL и кеш GLTF если он существует
|
||||
if (
|
||||
previousMediaUrlRef.current &&
|
||||
previousMediaUrlRef.current.startsWith("blob:")
|
||||
@@ -245,22 +260,10 @@ export const UploadMediaDialog = observer(
|
||||
|
||||
const newBlobUrl = URL.createObjectURL(mediaFile as Blob);
|
||||
setMediaUrl(newBlobUrl);
|
||||
previousMediaUrlRef.current = newBlobUrl; // Сохраняем новый URL в ref
|
||||
setIsPreviewLoaded(false); // Сбрасываем состояние загрузки при смене файла
|
||||
previousMediaUrlRef.current = newBlobUrl;
|
||||
setIsPreviewLoaded(false);
|
||||
}
|
||||
}, [mediaFile]); // Убираем mediaUrl из зависимостей чтобы избежать зацикливания
|
||||
|
||||
// const fileFormat = useEffect(() => {
|
||||
// const handleKeyPress = (event: KeyboardEvent) => {
|
||||
// if (event.key.toLowerCase() === "enter" && !event.ctrlKey) {
|
||||
// event.preventDefault();
|
||||
// onClose();
|
||||
// }
|
||||
// };
|
||||
|
||||
// window.addEventListener("keydown", handleKeyPress);
|
||||
// return () => window.removeEventListener("keydown", handleKeyPress);
|
||||
// }, [onClose]);
|
||||
}, [mediaFile]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!mediaFile) return;
|
||||
@@ -268,12 +271,16 @@ export const UploadMediaDialog = observer(
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const uploadStartTime = Date.now();
|
||||
|
||||
try {
|
||||
const effectiveMediaType = hardcodeType
|
||||
? (MEDIA_TYPE_VALUES[hardcodeType] as number)
|
||||
: mediaType;
|
||||
|
||||
const media = await uploadMedia(
|
||||
mediaFilename,
|
||||
hardcodeType
|
||||
? (MEDIA_TYPE_VALUES[hardcodeType] as number)
|
||||
: mediaType,
|
||||
effectiveMediaType,
|
||||
mediaFile,
|
||||
mediaName
|
||||
);
|
||||
@@ -284,11 +291,45 @@ export const UploadMediaDialog = observer(
|
||||
await afterUpload(media);
|
||||
}
|
||||
}
|
||||
|
||||
if (effectiveMediaType === 2) {
|
||||
const uploadDurationSec = Math.round((Date.now() - uploadStartTime) / 1000);
|
||||
const minutes = Math.floor(uploadDurationSec / 60);
|
||||
const seconds = uploadDurationSec % 60;
|
||||
const durationStr = minutes > 0
|
||||
? `${minutes} мин ${seconds} сек`
|
||||
: `${seconds} сек`;
|
||||
|
||||
const fileSizeMb = mediaFile.size / (1024 * 1024);
|
||||
const fileSizeStr = fileSizeMb >= 1024
|
||||
? `${(fileSizeMb / 1024).toFixed(2)} ГБ`
|
||||
: `${fileSizeMb.toFixed(1)} МБ`;
|
||||
|
||||
if (authStore.canRead("snapshots")) {
|
||||
try {
|
||||
await snapshotStore.getStorageInfo();
|
||||
const storage = snapshotStore.storageInfo;
|
||||
if (storage) {
|
||||
toast.success(
|
||||
`Видео (${fileSizeStr}) загружено за ${durationStr}. Свободно на диске: ${storage.available_disk_space_gb.toFixed(2)} ГБ из ${storage.total_disk_space_gb.toFixed(2)} ГБ`,
|
||||
{ autoClose: 8000 }
|
||||
);
|
||||
} else {
|
||||
toast.success(`Видео (${fileSizeStr}) загружено за ${durationStr}`, { autoClose: 6000 });
|
||||
}
|
||||
} catch {
|
||||
toast.success(`Видео (${fileSizeStr}) загружено за ${durationStr}`, { autoClose: 6000 });
|
||||
}
|
||||
} else {
|
||||
toast.success(`Видео (${fileSizeStr}) загружено за ${durationStr}`, { autoClose: 6000 });
|
||||
}
|
||||
}
|
||||
|
||||
setSuccess(true);
|
||||
// Закрываем модальное окно после успешного сохранения
|
||||
|
||||
setTimeout(() => {
|
||||
handleClose();
|
||||
}, 1000); // Небольшая задержка, чтобы пользователь увидел сообщение об успехе
|
||||
}, 1000);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to save media");
|
||||
} finally {
|
||||
@@ -297,7 +338,6 @@ export const UploadMediaDialog = observer(
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
// Очищаем blob URL и кеш GLTF при закрытии диалога
|
||||
if (
|
||||
previousMediaUrlRef.current &&
|
||||
previousMediaUrlRef.current.startsWith("blob:")
|
||||
@@ -310,7 +350,8 @@ export const UploadMediaDialog = observer(
|
||||
setMediaUrl(null);
|
||||
setMediaFile(null);
|
||||
setIsPreviewLoaded(false);
|
||||
previousMediaUrlRef.current = null; // Очищаем ref
|
||||
setIsPreviewBackgroundDark(false);
|
||||
previousMediaUrlRef.current = null;
|
||||
onClose();
|
||||
};
|
||||
|
||||
@@ -379,8 +420,18 @@ export const UploadMediaDialog = observer(
|
||||
justifyContent: "center",
|
||||
height: "100%",
|
||||
position: "relative",
|
||||
backgroundColor: isPreviewBackgroundDark ? "#000" : "#fff",
|
||||
transition: "background-color 150ms ease",
|
||||
}}
|
||||
>
|
||||
{mediaUrl && mediaType !== 2 && mediaType !== 6 && (
|
||||
<PreviewBackgroundToggle
|
||||
dark={isPreviewBackgroundDark}
|
||||
onToggle={() =>
|
||||
setIsPreviewBackgroundDark((value) => !value)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{!isPreviewLoaded && mediaUrl && (
|
||||
<Box
|
||||
sx={{
|
||||
|
||||
@@ -2,3 +2,4 @@ export * from "./SelectArticleDialog";
|
||||
export * from "./SelectMediaDialog";
|
||||
export * from "./PreviewMediaDialog";
|
||||
export * from "./UploadMediaDialog";
|
||||
export * from "./ArticleSelectOrCreateDialog";
|
||||
|
||||
@@ -12,6 +12,7 @@ export type Article = {
|
||||
heading: string;
|
||||
body: string;
|
||||
service_name: string;
|
||||
city_id?: number | null;
|
||||
ru?: {
|
||||
heading: string;
|
||||
body: string;
|
||||
|
||||
25
src/shared/store/AuthStore/api.ts
Normal file
25
src/shared/store/AuthStore/api.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { languageInstance } from "@shared";
|
||||
import { User, UserCity } from "../UserStore";
|
||||
|
||||
export const getMeApi = async (): Promise<User> => {
|
||||
const response = await languageInstance("ru").get("/auth/me");
|
||||
return response.data as User;
|
||||
};
|
||||
|
||||
export const getMeCitiesApi = async (): Promise<{
|
||||
ru: UserCity[];
|
||||
en: UserCity[];
|
||||
zh: UserCity[];
|
||||
}> => {
|
||||
const [ru, en, zh] = await Promise.all([
|
||||
languageInstance("ru").get("/auth/me"),
|
||||
languageInstance("en").get("/auth/me"),
|
||||
languageInstance("zh").get("/auth/me"),
|
||||
]);
|
||||
|
||||
return {
|
||||
ru: ((ru.data as User).cities ?? []),
|
||||
en: ((en.data as User).cities ?? []),
|
||||
zh: ((zh.data as User).cities ?? []),
|
||||
};
|
||||
};
|
||||
@@ -1,15 +1,13 @@
|
||||
import { API_URL, decodeJWT } from "@shared";
|
||||
import { API_URL, decodeJWT, mobxFetch } from "@shared";
|
||||
import { canRead as checkCanRead, canWrite as checkCanWrite } from "../../lib/permissions";
|
||||
import { makeAutoObservable, runInAction } from "mobx";
|
||||
import axios, { AxiosError } from "axios";
|
||||
import { User, UserCity } from "../UserStore";
|
||||
import { getMeApi, getMeCitiesApi } from "./api";
|
||||
|
||||
type LoginResponse = {
|
||||
token: string;
|
||||
user: {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
is_admin: boolean;
|
||||
};
|
||||
user: Pick<User, "id" | "name" | "email" | "is_admin" | "cities">;
|
||||
};
|
||||
|
||||
class AuthStore {
|
||||
@@ -48,7 +46,7 @@ class AuthStore {
|
||||
{
|
||||
email,
|
||||
password,
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const data = response.data;
|
||||
@@ -89,6 +87,76 @@ class AuthStore {
|
||||
get user() {
|
||||
return this.payload?.user;
|
||||
}
|
||||
|
||||
get isAdmin(): boolean {
|
||||
return (
|
||||
this.me?.is_admin === true ||
|
||||
(this.me?.roles ?? []).includes("admin")
|
||||
);
|
||||
}
|
||||
|
||||
me: User | null = null;
|
||||
meLoading = false;
|
||||
meError: string | null = null;
|
||||
|
||||
meCities: { ru: UserCity[]; en: UserCity[]; zh: UserCity[] } = {
|
||||
ru: [],
|
||||
en: [],
|
||||
zh: [],
|
||||
};
|
||||
|
||||
getMeAction = mobxFetch<void, User, AuthStore>({
|
||||
store: this,
|
||||
value: "me",
|
||||
loading: "meLoading",
|
||||
error: "meError",
|
||||
fn: getMeApi,
|
||||
onSuccess: () => {
|
||||
this.fetchMeCities();
|
||||
},
|
||||
});
|
||||
|
||||
fetchMeCities = async () => {
|
||||
const cities = await getMeCitiesApi();
|
||||
runInAction(() => {
|
||||
this.meCities = cities;
|
||||
});
|
||||
};
|
||||
|
||||
canWrite = (resource: string): boolean => {
|
||||
const roles = this.me?.roles ?? [];
|
||||
|
||||
if (roles.includes("admin")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (resource === "map") {
|
||||
return roles.some((role) =>
|
||||
["routes_rw", "stations_rw", "sights_rw"].includes(role),
|
||||
);
|
||||
}
|
||||
|
||||
return checkCanWrite(roles, resource);
|
||||
};
|
||||
|
||||
hasRole = (role: string): boolean => {
|
||||
const roles = this.me?.roles ?? [];
|
||||
return roles.includes("admin") || roles.includes(role);
|
||||
};
|
||||
|
||||
canRead = (resource: string): boolean => {
|
||||
if (resource === "map") {
|
||||
return this.canWrite("map");
|
||||
}
|
||||
return checkCanRead(this.me?.roles, resource);
|
||||
};
|
||||
|
||||
canAccess = (permission: string): boolean => {
|
||||
if (permission.includes("_")) {
|
||||
return this.hasRole(permission);
|
||||
}
|
||||
return this.canRead(permission);
|
||||
};
|
||||
}
|
||||
|
||||
export const authStore = new AuthStore();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
authInstance,
|
||||
authStore,
|
||||
cityStore,
|
||||
languageStore,
|
||||
languageInstance,
|
||||
@@ -11,13 +12,13 @@ export type Carrier = {
|
||||
id: number;
|
||||
short_name: string;
|
||||
full_name: string;
|
||||
slogan: string;
|
||||
city: string;
|
||||
city_id: number;
|
||||
logo: string;
|
||||
// main_color: string;
|
||||
// left_color: string;
|
||||
// right_color: string;
|
||||
preview_media?: string;
|
||||
video_preview?: string;
|
||||
main_color: string;
|
||||
left_color: string;
|
||||
right_color: string;
|
||||
};
|
||||
|
||||
type CarrierData = {
|
||||
@@ -110,57 +111,111 @@ class CarrierStore {
|
||||
|
||||
createCarrierData = {
|
||||
city_id: 0,
|
||||
logo: "",
|
||||
preview_media: "",
|
||||
video_preview: "",
|
||||
main_color: "",
|
||||
left_color: "",
|
||||
right_color: "",
|
||||
ru: {
|
||||
full_name: "",
|
||||
short_name: "",
|
||||
slogan: "",
|
||||
},
|
||||
en: {
|
||||
full_name: "",
|
||||
short_name: "",
|
||||
slogan: "",
|
||||
},
|
||||
zh: {
|
||||
full_name: "",
|
||||
short_name: "",
|
||||
slogan: "",
|
||||
},
|
||||
};
|
||||
|
||||
setCreateCarrierMedia = (
|
||||
field: "preview_media" | "video_preview",
|
||||
mediaId: string
|
||||
) => {
|
||||
this.createCarrierData[field] = mediaId;
|
||||
};
|
||||
|
||||
setCreateCarrierData = (
|
||||
fullName: string,
|
||||
shortName: string,
|
||||
cityId: number,
|
||||
slogan: string,
|
||||
logoId: string,
|
||||
language: Language
|
||||
language: Language,
|
||||
colors?: { main_color?: string; left_color?: string; right_color?: string }
|
||||
) => {
|
||||
this.createCarrierData.city_id = cityId;
|
||||
this.createCarrierData.logo = logoId;
|
||||
if (colors) {
|
||||
if (colors.main_color !== undefined) this.createCarrierData.main_color = colors.main_color;
|
||||
if (colors.left_color !== undefined) this.createCarrierData.left_color = colors.left_color;
|
||||
if (colors.right_color !== undefined) this.createCarrierData.right_color = colors.right_color;
|
||||
}
|
||||
this.createCarrierData[language] = {
|
||||
full_name: fullName,
|
||||
short_name: shortName,
|
||||
slogan: slogan,
|
||||
};
|
||||
};
|
||||
|
||||
private resolveCityName = (cityId: number, preferredLanguage: Language) => {
|
||||
if (!cityId) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const languages: Language[] = ["ru", "en", "zh"];
|
||||
|
||||
const fromCityStorePreferred = cityStore.cities[preferredLanguage].data.find(
|
||||
(city) => city.id === cityId
|
||||
)?.name;
|
||||
if (fromCityStorePreferred) {
|
||||
return fromCityStorePreferred;
|
||||
}
|
||||
|
||||
for (const language of languages) {
|
||||
const cityName = cityStore.cities[language].data.find(
|
||||
(city) => city.id === cityId
|
||||
)?.name;
|
||||
if (cityName) {
|
||||
return cityName;
|
||||
}
|
||||
}
|
||||
|
||||
const fromMePreferred = authStore.meCities[preferredLanguage].find(
|
||||
(city) => city.city_id === cityId
|
||||
)?.name;
|
||||
if (fromMePreferred) {
|
||||
return fromMePreferred;
|
||||
}
|
||||
|
||||
for (const language of languages) {
|
||||
const cityName = authStore.meCities[language].find(
|
||||
(city) => city.city_id === cityId
|
||||
)?.name;
|
||||
if (cityName) {
|
||||
return cityName;
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
};
|
||||
|
||||
createCarrier = async () => {
|
||||
const { language } = languageStore;
|
||||
const cityName =
|
||||
cityStore.cities[language].data.find(
|
||||
(city) => city.id === this.createCarrierData.city_id
|
||||
)?.name || "";
|
||||
const cityName = this.resolveCityName(this.createCarrierData.city_id, language);
|
||||
|
||||
const payload = {
|
||||
full_name: this.createCarrierData[language].full_name,
|
||||
short_name: this.createCarrierData[language].short_name,
|
||||
full_name: (this.createCarrierData[language].full_name || "").trim(),
|
||||
short_name: (this.createCarrierData[language].short_name || "").trim(),
|
||||
city: cityName,
|
||||
city_id: this.createCarrierData.city_id,
|
||||
slogan: this.createCarrierData[language].slogan,
|
||||
...(this.createCarrierData.logo
|
||||
? { logo: this.createCarrierData.logo }
|
||||
...(this.createCarrierData.preview_media
|
||||
? { preview_media: this.createCarrierData.preview_media }
|
||||
: {}),
|
||||
...(this.createCarrierData.video_preview
|
||||
? { video_preview: this.createCarrierData.video_preview }
|
||||
: {}),
|
||||
...(this.createCarrierData.main_color ? { main_color: this.createCarrierData.main_color } : {}),
|
||||
...(this.createCarrierData.left_color ? { left_color: this.createCarrierData.left_color } : {}),
|
||||
...(this.createCarrierData.right_color ? { right_color: this.createCarrierData.right_color } : {}),
|
||||
};
|
||||
|
||||
const response = await languageInstance(language).post("/carrier", payload);
|
||||
@@ -171,49 +226,55 @@ class CarrierStore {
|
||||
this.carriers[language].data.push(response.data);
|
||||
});
|
||||
|
||||
// Create translations for other languages
|
||||
for (const lang of ["ru", "en", "zh"].filter((l) => l !== language)) {
|
||||
for (const lang of (["ru", "en", "zh"] as const).filter(
|
||||
(item) => item !== language
|
||||
)) {
|
||||
const cityNameForLang = this.resolveCityName(
|
||||
this.createCarrierData.city_id,
|
||||
lang
|
||||
);
|
||||
const localizedData = this.createCarrierData[lang];
|
||||
const patchPayload = {
|
||||
// @ts-ignore
|
||||
full_name: this.createCarrierData[lang as any].full_name as string,
|
||||
// @ts-ignore
|
||||
short_name: this.createCarrierData[lang as any].short_name as string,
|
||||
city: cityName,
|
||||
full_name: (localizedData.full_name || "").trim(),
|
||||
short_name: (localizedData.short_name || "").trim(),
|
||||
city: cityNameForLang || cityName,
|
||||
city_id: this.createCarrierData.city_id,
|
||||
// @ts-ignore
|
||||
slogan: this.createCarrierData[lang as any].slogan as string,
|
||||
...(this.createCarrierData.logo
|
||||
? { logo: this.createCarrierData.logo }
|
||||
...(this.createCarrierData.preview_media
|
||||
? { preview_media: this.createCarrierData.preview_media }
|
||||
: {}),
|
||||
...(this.createCarrierData.video_preview
|
||||
? { video_preview: this.createCarrierData.video_preview }
|
||||
: {}),
|
||||
};
|
||||
|
||||
const response = await languageInstance(lang as Language).patch(
|
||||
const response = await languageInstance(lang).patch(
|
||||
`/carrier/${carrierId}`,
|
||||
patchPayload
|
||||
);
|
||||
|
||||
runInAction(() => {
|
||||
this.carriers[lang as keyof Carriers].data.push(response.data);
|
||||
this.carriers[lang].data.push(response.data);
|
||||
});
|
||||
}
|
||||
|
||||
this.createCarrierData = {
|
||||
city_id: 0,
|
||||
logo: "",
|
||||
preview_media: "",
|
||||
video_preview: "",
|
||||
main_color: "",
|
||||
left_color: "",
|
||||
right_color: "",
|
||||
ru: {
|
||||
full_name: "",
|
||||
short_name: "",
|
||||
slogan: "",
|
||||
},
|
||||
en: {
|
||||
full_name: "",
|
||||
short_name: "",
|
||||
slogan: "",
|
||||
},
|
||||
zh: {
|
||||
full_name: "",
|
||||
short_name: "",
|
||||
slogan: "",
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -222,72 +283,63 @@ class CarrierStore {
|
||||
ru: {
|
||||
full_name: "",
|
||||
short_name: "",
|
||||
|
||||
// main_color: "",
|
||||
// left_color: "",
|
||||
// right_color: "",
|
||||
slogan: "",
|
||||
},
|
||||
en: {
|
||||
full_name: "",
|
||||
short_name: "",
|
||||
|
||||
// main_color: "",
|
||||
// left_color: "",
|
||||
// right_color: "",
|
||||
slogan: "",
|
||||
},
|
||||
city_id: 0,
|
||||
logo: "",
|
||||
zh: {
|
||||
full_name: "",
|
||||
short_name: "",
|
||||
|
||||
// main_color: "",
|
||||
// left_color: "",
|
||||
// right_color: "",
|
||||
slogan: "",
|
||||
},
|
||||
city_id: 0,
|
||||
preview_media: "",
|
||||
video_preview: "",
|
||||
main_color: "",
|
||||
left_color: "",
|
||||
right_color: "",
|
||||
};
|
||||
|
||||
setEditCarrierMedia = (
|
||||
field: "preview_media" | "video_preview",
|
||||
mediaId: string
|
||||
) => {
|
||||
this.editCarrierData[field] = mediaId;
|
||||
};
|
||||
|
||||
setEditCarrierData = (
|
||||
fullName: string,
|
||||
shortName: string,
|
||||
cityId: number,
|
||||
// main_color: string,
|
||||
// left_color: string,
|
||||
// right_color: string,
|
||||
slogan: string,
|
||||
logoId: string,
|
||||
language: Language
|
||||
language: Language,
|
||||
colors?: { main_color?: string; left_color?: string; right_color?: string }
|
||||
) => {
|
||||
this.editCarrierData.city_id = cityId;
|
||||
this.editCarrierData.logo = logoId;
|
||||
if (colors) {
|
||||
if (colors.main_color !== undefined) this.editCarrierData.main_color = colors.main_color;
|
||||
if (colors.left_color !== undefined) this.editCarrierData.left_color = colors.left_color;
|
||||
if (colors.right_color !== undefined) this.editCarrierData.right_color = colors.right_color;
|
||||
}
|
||||
this.editCarrierData[language] = {
|
||||
full_name: fullName,
|
||||
short_name: shortName,
|
||||
// main_color: main_color,
|
||||
// left_color: left_color,
|
||||
// right_color: right_color,
|
||||
slogan: slogan,
|
||||
};
|
||||
};
|
||||
|
||||
editCarrier = async (id: number) => {
|
||||
const { language } = languageStore;
|
||||
const cityName =
|
||||
cityStore.cities[language].data.find(
|
||||
(city) => city.id === this.editCarrierData.city_id
|
||||
)?.name || "";
|
||||
|
||||
for (const lang of ["ru", "en", "zh"] as const) {
|
||||
const cityName = this.resolveCityName(this.editCarrierData.city_id, lang);
|
||||
const response = await languageInstance(lang).patch(`/carrier/${id}`, {
|
||||
...this.editCarrierData[lang],
|
||||
full_name: (this.editCarrierData[lang].full_name || "").trim(),
|
||||
short_name: (this.editCarrierData[lang].short_name || "").trim(),
|
||||
city: cityName,
|
||||
city_id: this.editCarrierData.city_id,
|
||||
...(this.editCarrierData.logo
|
||||
? { logo: this.editCarrierData.logo }
|
||||
: {}),
|
||||
preview_media: this.editCarrierData.preview_media || null,
|
||||
video_preview: this.editCarrierData.video_preview || null,
|
||||
...(this.editCarrierData.main_color ? { main_color: this.editCarrierData.main_color } : {}),
|
||||
...(this.editCarrierData.left_color ? { left_color: this.editCarrierData.left_color } : {}),
|
||||
...(this.editCarrierData.right_color ? { right_color: this.editCarrierData.right_color } : {}),
|
||||
});
|
||||
|
||||
runInAction(() => {
|
||||
|
||||
@@ -14,6 +14,7 @@ export type City = {
|
||||
country: string;
|
||||
country_code: string;
|
||||
arms: string;
|
||||
weather_city_code?: number;
|
||||
};
|
||||
|
||||
export type CashedCities = {
|
||||
@@ -132,6 +133,7 @@ class CityStore {
|
||||
createCityData = {
|
||||
country_code: "",
|
||||
arms: "",
|
||||
weather_city_code: 0,
|
||||
ru: {
|
||||
name: "",
|
||||
},
|
||||
@@ -159,9 +161,13 @@ class CityStore {
|
||||
};
|
||||
};
|
||||
|
||||
setCreateCityWeatherCode = (weather_city_code: number) => {
|
||||
this.createCityData = { ...this.createCityData, weather_city_code };
|
||||
};
|
||||
|
||||
async createCity() {
|
||||
const language = languageStore.language as Language;
|
||||
const { country_code, arms } = this.createCityData;
|
||||
const { country_code, arms, weather_city_code } = this.createCityData;
|
||||
const { name } = this.createCityData[language];
|
||||
|
||||
if (!name || !country_code) {
|
||||
@@ -171,13 +177,14 @@ class CityStore {
|
||||
try {
|
||||
// Create city in primary language
|
||||
const cityPayload = {
|
||||
name,
|
||||
name: name.trim(),
|
||||
country:
|
||||
countryStore.countries[language as keyof CashedCountries]?.data.find(
|
||||
(c) => c.code === country_code
|
||||
)?.name || "",
|
||||
country_code,
|
||||
...(arms ? { arms } : {}),
|
||||
weather_city_code: weather_city_code ?? 0,
|
||||
};
|
||||
|
||||
const cityResponse = await languageInstance(language).post(
|
||||
@@ -200,7 +207,7 @@ class CityStore {
|
||||
)?.name || "";
|
||||
|
||||
const patchPayload = {
|
||||
name: secondaryName || "",
|
||||
name: (secondaryName || "").trim(),
|
||||
country: countryName,
|
||||
country_code: country_code || "",
|
||||
...(arms ? { arms } : {}),
|
||||
@@ -232,6 +239,7 @@ class CityStore {
|
||||
this.createCityData = {
|
||||
country_code: "",
|
||||
arms: "",
|
||||
weather_city_code: 0,
|
||||
ru: { name: "" },
|
||||
en: { name: "" },
|
||||
zh: { name: "" },
|
||||
@@ -246,6 +254,7 @@ class CityStore {
|
||||
editCityData = {
|
||||
country_code: "",
|
||||
arms: "",
|
||||
weather_city_code: 0,
|
||||
ru: {
|
||||
name: "",
|
||||
},
|
||||
@@ -267,16 +276,19 @@ class CityStore {
|
||||
...this.editCityData,
|
||||
country_code: country_code,
|
||||
arms: arms,
|
||||
|
||||
[language]: {
|
||||
name: name,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
setEditCityWeatherCode = (weather_city_code: number) => {
|
||||
this.editCityData = { ...this.editCityData, weather_city_code };
|
||||
};
|
||||
|
||||
editCity = async (code: string) => {
|
||||
for (const language of ["ru", "en", "zh"]) {
|
||||
const { country_code, arms } = this.editCityData;
|
||||
const { country_code, arms, weather_city_code } = this.editCityData;
|
||||
const { name } = this.editCityData[language as keyof CashedCities];
|
||||
const { countries } = countryStore;
|
||||
|
||||
@@ -285,10 +297,11 @@ class CityStore {
|
||||
);
|
||||
|
||||
await languageInstance(language as Language).patch(`/city/${code}`, {
|
||||
name,
|
||||
name: (name || "").trim(),
|
||||
country: country?.name || "",
|
||||
country_code: country_code,
|
||||
arms,
|
||||
weather_city_code: weather_city_code ?? 0,
|
||||
});
|
||||
|
||||
runInAction(() => {
|
||||
|
||||
@@ -136,7 +136,7 @@ class CountryStore {
|
||||
if (code && this.createCountryData[language].name) {
|
||||
await languageInstance(language as Language).post("/country", {
|
||||
code: code,
|
||||
name: name,
|
||||
name: name.trim(),
|
||||
});
|
||||
|
||||
runInAction(() => {
|
||||
@@ -156,7 +156,7 @@ class CountryStore {
|
||||
await languageInstance(secondaryLanguage as Language).patch(
|
||||
`/country/${code}`,
|
||||
{
|
||||
name: name,
|
||||
name: name.trim(),
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -212,7 +212,7 @@ class CountryStore {
|
||||
|
||||
if (name) {
|
||||
await languageInstance(language as Language).patch(`/country/${code}`, {
|
||||
name: name,
|
||||
name: name.trim(),
|
||||
});
|
||||
|
||||
runInAction(() => {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// @shared/stores/createSightStore.ts
|
||||
import {
|
||||
articlesStore,
|
||||
Language,
|
||||
authInstance,
|
||||
languageInstance,
|
||||
mediaStore,
|
||||
selectedCityStore,
|
||||
} from "@shared";
|
||||
import { makeAutoObservable, runInAction } from "mobx";
|
||||
|
||||
@@ -17,6 +17,7 @@ type MediaItem = {
|
||||
|
||||
type SightLanguageInfo = {
|
||||
name: string;
|
||||
short_name: string;
|
||||
address: string;
|
||||
left: {
|
||||
heading: string;
|
||||
@@ -27,21 +28,22 @@ type SightLanguageInfo = {
|
||||
};
|
||||
|
||||
type SightCommonInfo = {
|
||||
// id: number; // ID is 0 until created
|
||||
city_id: number;
|
||||
city: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
is_default_icon: boolean;
|
||||
thumbnail: string | null;
|
||||
icon: string | null;
|
||||
alt_icon: string | null;
|
||||
watermark_lu: string | null;
|
||||
watermark_rd: string | null;
|
||||
left_article: number; // Can be 0 or a real ID, or placeholder like 10000000
|
||||
left_article: number;
|
||||
preview_media: string | null;
|
||||
video_preview: string | null;
|
||||
preview_font_size?: number;
|
||||
};
|
||||
|
||||
// SightBaseInfo combines common info with language-specific info
|
||||
// The 'id' for the sight itself will be assigned upon creation by the backend.
|
||||
type SightBaseInfo = SightCommonInfo & {
|
||||
[key in Language]: SightLanguageInfo;
|
||||
};
|
||||
@@ -51,7 +53,10 @@ const initialSightState: SightBaseInfo = {
|
||||
city: "",
|
||||
latitude: 0,
|
||||
longitude: 0,
|
||||
is_default_icon: false,
|
||||
thumbnail: null,
|
||||
icon: null,
|
||||
alt_icon: null,
|
||||
watermark_lu: null,
|
||||
watermark_rd: null,
|
||||
left_article: 0,
|
||||
@@ -59,18 +64,21 @@ const initialSightState: SightBaseInfo = {
|
||||
video_preview: null,
|
||||
ru: {
|
||||
name: "",
|
||||
short_name: "",
|
||||
address: "",
|
||||
left: { heading: "", body: "", media: [] },
|
||||
right: [],
|
||||
},
|
||||
en: {
|
||||
name: "",
|
||||
short_name: "",
|
||||
address: "",
|
||||
left: { heading: "", body: "", media: [] },
|
||||
right: [],
|
||||
},
|
||||
zh: {
|
||||
name: "",
|
||||
short_name: "",
|
||||
address: "",
|
||||
left: { heading: "", body: "", media: [] },
|
||||
right: [],
|
||||
@@ -78,7 +86,7 @@ const initialSightState: SightBaseInfo = {
|
||||
};
|
||||
|
||||
class CreateSightStore {
|
||||
sight: SightBaseInfo = JSON.parse(JSON.stringify(initialSightState)); // Deep copy for reset
|
||||
sight: SightBaseInfo = JSON.parse(JSON.stringify(initialSightState));
|
||||
|
||||
uploadMediaOpen = false;
|
||||
setUploadMediaOpen = (open: boolean) => {
|
||||
@@ -93,9 +101,7 @@ class CreateSightStore {
|
||||
makeAutoObservable(this);
|
||||
}
|
||||
|
||||
// --- Right Article Management ---
|
||||
createNewRightArticle = async () => {
|
||||
// Create article in DB for all languages
|
||||
const articleRuData = {
|
||||
heading: "Новый заголовок (RU)",
|
||||
body: "Новый текст (RU)",
|
||||
@@ -124,8 +130,9 @@ class CreateSightStore {
|
||||
zh: articleZhData.body,
|
||||
},
|
||||
},
|
||||
...(selectedCityStore.selectedCityId ? { city_id: selectedCityStore.selectedCityId } : {}),
|
||||
});
|
||||
const { id } = articleRes.data; // New article's ID
|
||||
const { id } = articleRes.data;
|
||||
|
||||
runInAction(() => {
|
||||
const newArticleEntry = { id, media: [] };
|
||||
@@ -133,7 +140,7 @@ class CreateSightStore {
|
||||
this.sight.en.right.push({ ...newArticleEntry, ...articleEnData });
|
||||
this.sight.zh.right.push({ ...newArticleEntry, ...articleZhData });
|
||||
});
|
||||
return id; // Return ID for potential immediate use
|
||||
return id;
|
||||
} catch (error) {
|
||||
console.error("Error creating new right article:", error);
|
||||
throw error;
|
||||
@@ -169,7 +176,7 @@ class CreateSightStore {
|
||||
});
|
||||
});
|
||||
|
||||
return articleId; // Return the linked article ID
|
||||
return articleId;
|
||||
} catch (error) {
|
||||
console.error("Error linking existing right article:", error);
|
||||
throw error;
|
||||
@@ -180,7 +187,7 @@ class CreateSightStore {
|
||||
index: number,
|
||||
language: Language,
|
||||
heading: string,
|
||||
body: string
|
||||
body: string,
|
||||
) => {
|
||||
if (this.sight[language].right[index]) {
|
||||
this.sight[language].right[index].heading = heading;
|
||||
@@ -188,38 +195,32 @@ class CreateSightStore {
|
||||
}
|
||||
};
|
||||
|
||||
// "Unlink" in create mode means just removing from the list to be created with the sight
|
||||
unlinkRightAritcle = (articleId: number) => {
|
||||
// Changed from 'unlinkRightAritcle' spelling
|
||||
runInAction(() => {
|
||||
this.sight.ru.right = this.sight.ru.right.filter(
|
||||
(article) => article.id !== articleId
|
||||
(article) => article.id !== articleId,
|
||||
);
|
||||
this.sight.en.right = this.sight.en.right.filter(
|
||||
(article) => article.id !== articleId
|
||||
(article) => article.id !== articleId,
|
||||
);
|
||||
this.sight.zh.right = this.sight.zh.right.filter(
|
||||
(article) => article.id !== articleId
|
||||
(article) => article.id !== articleId,
|
||||
);
|
||||
});
|
||||
// Note: If this article was created via createNewRightArticle, it still exists in the DB.
|
||||
// Consider if an orphaned article should be deleted here or managed separately.
|
||||
// For now, it just removes it from the list associated with *this specific sight creation process*.
|
||||
};
|
||||
|
||||
deleteRightArticle = async (articleId: number) => {
|
||||
try {
|
||||
await authInstance.delete(`/article/${articleId}`); // Delete from backend
|
||||
await authInstance.delete(`/article/${articleId}`);
|
||||
runInAction(() => {
|
||||
// Remove from local store for all languages
|
||||
this.sight.ru.right = this.sight.ru.right.filter(
|
||||
(article) => article.id !== articleId
|
||||
(article) => article.id !== articleId,
|
||||
);
|
||||
this.sight.en.right = this.sight.en.right.filter(
|
||||
(article) => article.id !== articleId
|
||||
(article) => article.id !== articleId,
|
||||
);
|
||||
this.sight.zh.right = this.sight.zh.right.filter(
|
||||
(article) => article.id !== articleId
|
||||
(article) => article.id !== articleId,
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -228,21 +229,20 @@ class CreateSightStore {
|
||||
}
|
||||
};
|
||||
|
||||
// --- Right Article Media Management ---
|
||||
createLinkWithRightArticle = async (media: MediaItem, articleId: number) => {
|
||||
try {
|
||||
await authInstance.post(`/article/${articleId}/media`, {
|
||||
media_id: media.id,
|
||||
media_order: 1, // Or calculate based on existing media.length + 1
|
||||
media_order: 1,
|
||||
});
|
||||
runInAction(() => {
|
||||
(["ru", "en", "zh"] as Language[]).forEach((lang) => {
|
||||
const article = this.sight[lang].right.find(
|
||||
(a) => a.id === articleId
|
||||
(a) => a.id === articleId,
|
||||
);
|
||||
if (article) {
|
||||
if (!article.media) article.media = [];
|
||||
article.media.unshift(media); // Add to the beginning
|
||||
article.media.unshift(media);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -260,7 +260,7 @@ class CreateSightStore {
|
||||
runInAction(() => {
|
||||
(["ru", "en", "zh"] as Language[]).forEach((lang) => {
|
||||
const article = this.sight[lang].right.find(
|
||||
(a) => a.id === articleId
|
||||
(a) => a.id === articleId,
|
||||
);
|
||||
if (article && article.media) {
|
||||
article.media = article.media.filter((m) => m.id !== mediaId);
|
||||
@@ -273,7 +273,6 @@ class CreateSightStore {
|
||||
}
|
||||
};
|
||||
|
||||
// --- Left Article Management (largely unchanged from your provided store) ---
|
||||
updateLeftInfo = (language: Language, heading: string, body: string) => {
|
||||
this.sight[language].left.heading = heading;
|
||||
this.sight[language].left.body = body;
|
||||
@@ -323,16 +322,16 @@ class CreateSightStore {
|
||||
deleteLeftArticle = async (articleId: number) => {
|
||||
/* ... your existing logic ... */
|
||||
await authInstance.delete(`/article/${articleId}`);
|
||||
// articlesStore.getArticles(languageStore.language); // If still neede
|
||||
|
||||
runInAction(() => {
|
||||
articlesStore.articles.ru = articlesStore.articles.ru.filter(
|
||||
(article) => article.id !== articleId
|
||||
(article) => article.id !== articleId,
|
||||
);
|
||||
articlesStore.articles.en = articlesStore.articles.en.filter(
|
||||
(article) => article.id !== articleId
|
||||
(article) => article.id !== articleId,
|
||||
);
|
||||
articlesStore.articles.zh = articlesStore.articles.zh.filter(
|
||||
(article) => article.id !== articleId
|
||||
(article) => article.id !== articleId,
|
||||
);
|
||||
});
|
||||
this.unlinkLeftArticle();
|
||||
@@ -340,63 +339,70 @@ class CreateSightStore {
|
||||
|
||||
createLeftArticle = async () => {
|
||||
/* ... your existing logic to create a new left article (placeholder or DB) ... */
|
||||
const ruName = (this.sight.ru.name || "").trim();
|
||||
const enName = (this.sight.en.name || "").trim();
|
||||
const zhName = (this.sight.zh.name || "").trim();
|
||||
|
||||
const hasAnyName = !!(ruName || enName || zhName);
|
||||
|
||||
const response = await languageInstance("ru").post("/article", {
|
||||
heading: "Новая левая статья",
|
||||
body: "Заполните контентом",
|
||||
heading: hasAnyName ? ruName : "",
|
||||
body: "",
|
||||
...(selectedCityStore.selectedCityId ? { city_id: selectedCityStore.selectedCityId } : {}),
|
||||
});
|
||||
const newLeftArticleId = response.data.id;
|
||||
|
||||
await languageInstance("en").patch(`/article/${newLeftArticleId}`, {
|
||||
heading: "New Left Article",
|
||||
body: "Fill with content",
|
||||
heading: hasAnyName ? enName : "",
|
||||
body: "",
|
||||
});
|
||||
await languageInstance("zh").patch(`/article/${newLeftArticleId}`, {
|
||||
heading: "新的左侧文章",
|
||||
body: "填写内容",
|
||||
heading: hasAnyName ? zhName : "",
|
||||
body: "",
|
||||
});
|
||||
|
||||
runInAction(() => {
|
||||
this.sight.left_article = newLeftArticleId; // Store the actual ID
|
||||
this.sight.left_article = newLeftArticleId;
|
||||
this.sight.ru.left = {
|
||||
heading: "Новая левая статья",
|
||||
body: "Заполните контентом",
|
||||
heading: hasAnyName ? ruName : "",
|
||||
body: "",
|
||||
media: [],
|
||||
};
|
||||
this.sight.en.left = {
|
||||
heading: "New Left Article",
|
||||
body: "Fill with content",
|
||||
heading: hasAnyName ? enName : "",
|
||||
body: "",
|
||||
media: [],
|
||||
};
|
||||
this.sight.zh.left = {
|
||||
heading: "新的左侧文章",
|
||||
body: "填写内容",
|
||||
heading: hasAnyName ? zhName : "",
|
||||
body: "",
|
||||
media: [],
|
||||
};
|
||||
|
||||
articlesStore.articles.ru.push({
|
||||
id: newLeftArticleId,
|
||||
heading: "Новая левая статья",
|
||||
body: "Заполните контентом",
|
||||
service_name: "Новая левая статья",
|
||||
heading: hasAnyName ? ruName : "",
|
||||
body: "",
|
||||
service_name: hasAnyName ? ruName : "",
|
||||
});
|
||||
articlesStore.articles.en.push({
|
||||
id: newLeftArticleId,
|
||||
heading: "New Left Article",
|
||||
body: "Fill with content",
|
||||
service_name: "New Left Article",
|
||||
heading: hasAnyName ? enName : "",
|
||||
body: "",
|
||||
service_name: hasAnyName ? enName : "",
|
||||
});
|
||||
articlesStore.articles.zh.push({
|
||||
id: newLeftArticleId,
|
||||
heading: "新的左侧文章",
|
||||
body: "填写内容",
|
||||
service_name: "新的左侧文章",
|
||||
heading: hasAnyName ? zhName : "",
|
||||
body: "",
|
||||
service_name: hasAnyName ? zhName : "",
|
||||
});
|
||||
});
|
||||
return newLeftArticleId;
|
||||
};
|
||||
|
||||
// Placeholder for a "new" unsaved left article
|
||||
setNewLeftArticlePlaceholder = () => {
|
||||
this.sight.left_article = 10000000; // Special placeholder ID
|
||||
this.sight.left_article = 10000000;
|
||||
this.sight.ru.left = {
|
||||
heading: "Новая левая статья",
|
||||
body: "Заполните контентом",
|
||||
@@ -414,7 +420,6 @@ class CreateSightStore {
|
||||
};
|
||||
};
|
||||
|
||||
// --- Sight Preview Media ---
|
||||
linkPreviewMedia = (mediaId: string) => {
|
||||
this.sight.preview_media = mediaId;
|
||||
};
|
||||
@@ -423,35 +428,31 @@ class CreateSightStore {
|
||||
this.sight.preview_media = null;
|
||||
};
|
||||
|
||||
// --- General Store Methods ---
|
||||
clearCreateSight = () => {
|
||||
this.needLeaveAgree = false;
|
||||
this.sight = JSON.parse(JSON.stringify(initialSightState)); // Reset to initial
|
||||
this.sight = JSON.parse(JSON.stringify(initialSightState));
|
||||
};
|
||||
|
||||
updateSightInfo = (
|
||||
content: Partial<SightLanguageInfo | SightCommonInfo>, // Corrected types
|
||||
language?: Language
|
||||
content: Partial<SightLanguageInfo | SightCommonInfo>,
|
||||
language?: Language,
|
||||
) => {
|
||||
this.needLeaveAgree = true;
|
||||
if (language) {
|
||||
this.sight[language] = { ...this.sight[language], ...content };
|
||||
} else {
|
||||
// Assuming content here is for SightCommonInfo
|
||||
this.sight = { ...this.sight, ...(content as Partial<SightCommonInfo>) };
|
||||
}
|
||||
};
|
||||
|
||||
// --- Main Sight Creation Logic ---
|
||||
createSight = async (primaryLanguage: Language) => {
|
||||
let finalLeftArticleId = this.sight.left_article;
|
||||
|
||||
// 1. Handle Left Article (Create if new, or use existing ID)
|
||||
if (this.sight.left_article === 10000000) {
|
||||
// Placeholder for new
|
||||
const res = await languageInstance("ru").post("/article", {
|
||||
heading: this.sight.ru.left.heading,
|
||||
body: this.sight.ru.left.body,
|
||||
...(selectedCityStore.selectedCityId ? { city_id: selectedCityStore.selectedCityId } : {}),
|
||||
});
|
||||
finalLeftArticleId = res.data.id;
|
||||
await languageInstance("en").patch(`/article/${finalLeftArticleId}`, {
|
||||
@@ -466,24 +467,20 @@ class CreateSightStore {
|
||||
this.sight.left_article !== 0 &&
|
||||
this.sight.left_article !== null
|
||||
) {
|
||||
// Existing, ensure it's up-to-date
|
||||
await languageInstance("ru").patch(
|
||||
`/article/${this.sight.left_article}`,
|
||||
{ heading: this.sight.ru.left.heading, body: this.sight.ru.left.body }
|
||||
{ heading: this.sight.ru.left.heading, body: this.sight.ru.left.body },
|
||||
);
|
||||
await languageInstance("en").patch(
|
||||
`/article/${this.sight.left_article}`,
|
||||
{ heading: this.sight.en.left.heading, body: this.sight.en.left.body }
|
||||
{ heading: this.sight.en.left.heading, body: this.sight.en.left.body },
|
||||
);
|
||||
await languageInstance("zh").patch(
|
||||
`/article/${this.sight.left_article}`,
|
||||
{ heading: this.sight.zh.left.heading, body: this.sight.zh.left.body }
|
||||
{ heading: this.sight.zh.left.heading, body: this.sight.zh.left.body },
|
||||
);
|
||||
}
|
||||
// else: left_article is 0, so no left article
|
||||
|
||||
// 2. Right articles are already created in DB and their IDs are in this.sight[lang].right.
|
||||
// We just need to update their content if changed before saving the sight.
|
||||
for (const lang of ["ru", "en", "zh"] as Language[]) {
|
||||
for (const article of this.sight[lang].right) {
|
||||
if (article.id == 0 || article.id == null) {
|
||||
@@ -493,38 +490,40 @@ class CreateSightStore {
|
||||
heading: article.heading,
|
||||
body: article.body,
|
||||
});
|
||||
// Media for these articles are already linked via createLinkWithRightArticle
|
||||
}
|
||||
}
|
||||
const rightArticleIdsForLink = this.sight[primaryLanguage].right.map(
|
||||
(a) => a.id
|
||||
(a) => a.id,
|
||||
);
|
||||
|
||||
// 3. Create Sight object in DB
|
||||
const sightPayload = {
|
||||
city_id: this.sight.city_id,
|
||||
city: this.sight.city,
|
||||
latitude: this.sight.latitude,
|
||||
longitude: this.sight.longitude,
|
||||
name: this.sight[primaryLanguage].name,
|
||||
is_default_icon: this.sight.is_default_icon,
|
||||
name: (this.sight[primaryLanguage].name || "").trim(),
|
||||
short_name: (this.sight[primaryLanguage].short_name || "").trim(),
|
||||
address: this.sight[primaryLanguage].address,
|
||||
thumbnail: this.sight.thumbnail,
|
||||
icon: this.sight.icon,
|
||||
alt_icon: this.sight.alt_icon,
|
||||
watermark_lu: this.sight.watermark_lu,
|
||||
watermark_rd: this.sight.watermark_rd,
|
||||
left_article: finalLeftArticleId === 0 ? null : finalLeftArticleId,
|
||||
preview_media: this.sight.preview_media,
|
||||
video_preview: this.sight.video_preview,
|
||||
preview_font_size: this.sight.preview_font_size,
|
||||
};
|
||||
|
||||
const response = await languageInstance(primaryLanguage).post(
|
||||
"/sight",
|
||||
sightPayload
|
||||
sightPayload,
|
||||
);
|
||||
const newSightId = response.data.id; // ID of the newly created sight
|
||||
const newSightId = response.data.id;
|
||||
|
||||
// 4. Update other languages for the sight
|
||||
const otherLanguages = (["ru", "en", "zh"] as Language[]).filter(
|
||||
(l) => l !== primaryLanguage
|
||||
(l) => l !== primaryLanguage,
|
||||
);
|
||||
for (const lang of otherLanguages) {
|
||||
await languageInstance(lang).patch(`/sight/${newSightId}`, {
|
||||
@@ -532,9 +531,13 @@ class CreateSightStore {
|
||||
city: this.sight.city,
|
||||
latitude: this.sight.latitude,
|
||||
longitude: this.sight.longitude,
|
||||
name: this.sight[lang].name,
|
||||
is_default_icon: this.sight.is_default_icon,
|
||||
name: (this.sight[lang].name || "").trim(),
|
||||
short_name: (this.sight[lang].short_name || "").trim(),
|
||||
address: this.sight[lang].address,
|
||||
thumbnail: this.sight.thumbnail,
|
||||
icon: this.sight.icon,
|
||||
alt_icon: this.sight.alt_icon,
|
||||
watermark_lu: this.sight.watermark_lu,
|
||||
watermark_rd: this.sight.watermark_rd,
|
||||
left_article: finalLeftArticleId === 0 ? null : finalLeftArticleId,
|
||||
@@ -543,31 +546,34 @@ class CreateSightStore {
|
||||
});
|
||||
}
|
||||
|
||||
// 5. Link Right Articles to the new Sight
|
||||
for (let i = 0; i < rightArticleIdsForLink.length; i++) {
|
||||
await authInstance.post(`/sight/${newSightId}/article`, {
|
||||
article_id: rightArticleIdsForLink[i],
|
||||
page_num: i + 1, // Or other logic for page_num
|
||||
page_num: i + 1,
|
||||
});
|
||||
}
|
||||
|
||||
// Optionally: this.clearCreateSight(); // To reset form after successful creation
|
||||
this.needLeaveAgree = false;
|
||||
runInAction(() => {
|
||||
this.needLeaveAgree = false;
|
||||
});
|
||||
|
||||
return newSightId;
|
||||
};
|
||||
|
||||
// --- Media Upload (Generic, used by dialogs) ---
|
||||
uploadMedia = async (
|
||||
filename: string,
|
||||
type: number,
|
||||
file: File,
|
||||
media_name?: string
|
||||
media_name?: string,
|
||||
): Promise<MediaItem> => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append("filename", filename);
|
||||
if (media_name) formData.append("media_name", media_name);
|
||||
formData.append("type", type.toString());
|
||||
if (selectedCityStore.selectedCityId) {
|
||||
formData.append("city_id", selectedCityStore.selectedCityId.toString());
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await authInstance.post(`/media`, formData);
|
||||
@@ -575,12 +581,12 @@ class CreateSightStore {
|
||||
this.fileToUpload = null;
|
||||
this.uploadMediaOpen = false;
|
||||
});
|
||||
mediaStore.getMedia(); // Refresh global media list
|
||||
mediaStore.getMedia();
|
||||
return {
|
||||
id: response.data.id,
|
||||
filename: filename, // Or response.data.filename if backend returns it
|
||||
media_name: media_name, // Or response.data.media_name
|
||||
media_type: type, // Or response.data.type
|
||||
filename: filename,
|
||||
media_name: media_name,
|
||||
media_type: type,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error uploading media:", error);
|
||||
@@ -588,15 +594,12 @@ class CreateSightStore {
|
||||
}
|
||||
};
|
||||
|
||||
// For Left Article Media
|
||||
createLinkWithLeftArticle = async (media: MediaItem) => {
|
||||
if (!this.sight.left_article || this.sight.left_article === 10000000) {
|
||||
console.warn(
|
||||
"Left article not selected or is a placeholder. Cannot link media yet."
|
||||
"Left article not selected or is a placeholder. Cannot link media yet.",
|
||||
);
|
||||
// If it's a placeholder, we could store the media temporarily and link it after the article is created.
|
||||
// For simplicity, we'll assume the article must exist.
|
||||
// A more robust solution might involve creating the article first if it's a placeholder.
|
||||
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -627,7 +630,7 @@ class CreateSightStore {
|
||||
(["ru", "en", "zh"] as Language[]).forEach((lang) => {
|
||||
if (this.sight[lang].left.media) {
|
||||
this.sight[lang].left.media = this.sight[lang].left.media.filter(
|
||||
(m) => m.id !== mediaId
|
||||
(m) => m.id !== mediaId,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -643,19 +646,19 @@ class CreateSightStore {
|
||||
|
||||
const sortArticles = (existing: any[]) => {
|
||||
const articleMap = new Map(
|
||||
existing.map((article) => [article.id, article])
|
||||
existing.map((article) => [article.id, article]),
|
||||
);
|
||||
return articlesIds
|
||||
.map((id) => articleMap.get(id))
|
||||
.filter(
|
||||
(article): article is (typeof existing)[number] =>
|
||||
article !== undefined
|
||||
article !== undefined,
|
||||
);
|
||||
};
|
||||
|
||||
this.sight.ru.right = sortArticles(this.sight.ru.right);
|
||||
this.sight.en.right = sortArticles(this.sight.en.right);
|
||||
this.sight.zh.right = sortArticles(this.sight.zh.right); // теперь zh тоже сортируется одинаково
|
||||
this.sight.zh.right = sortArticles(this.sight.zh.right);
|
||||
|
||||
this.needLeaveAgree = true;
|
||||
};
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
// @shared/stores/editSightStore.ts
|
||||
import {
|
||||
articlesStore,
|
||||
authInstance,
|
||||
Language,
|
||||
languageInstance,
|
||||
mediaStore,
|
||||
selectedCityStore,
|
||||
} from "@shared";
|
||||
import { makeAutoObservable, runInAction } from "mobx";
|
||||
|
||||
export type SightLanguageInfo = {
|
||||
id: number;
|
||||
name: string;
|
||||
short_name: string;
|
||||
address: string;
|
||||
left: {
|
||||
heading: string;
|
||||
@@ -31,12 +32,16 @@ export type SightCommonInfo = {
|
||||
city: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
is_default_icon: boolean;
|
||||
thumbnail: string | null;
|
||||
icon: string | null;
|
||||
alt_icon: string | null;
|
||||
watermark_lu: string | null;
|
||||
watermark_rd: string | null;
|
||||
left_article: number;
|
||||
preview_media: string | null;
|
||||
video_preview: string | null;
|
||||
preview_font_size?: number;
|
||||
};
|
||||
|
||||
export type SightBaseInfo = {
|
||||
@@ -52,7 +57,10 @@ class EditSightStore {
|
||||
city: "",
|
||||
latitude: 0,
|
||||
longitude: 0,
|
||||
is_default_icon: false,
|
||||
thumbnail: null,
|
||||
icon: null,
|
||||
alt_icon: null,
|
||||
watermark_lu: null,
|
||||
watermark_rd: null,
|
||||
left_article: 0,
|
||||
@@ -62,6 +70,7 @@ class EditSightStore {
|
||||
ru: {
|
||||
id: 0,
|
||||
name: "",
|
||||
short_name: "",
|
||||
address: "",
|
||||
left: { heading: "", body: "", media: [] },
|
||||
right: [],
|
||||
@@ -69,6 +78,7 @@ class EditSightStore {
|
||||
en: {
|
||||
id: 0,
|
||||
name: "",
|
||||
short_name: "",
|
||||
address: "",
|
||||
left: { heading: "", body: "", media: [] },
|
||||
right: [],
|
||||
@@ -76,6 +86,7 @@ class EditSightStore {
|
||||
zh: {
|
||||
id: 0,
|
||||
name: "",
|
||||
short_name: "",
|
||||
address: "",
|
||||
left: { heading: "", body: "", media: [] },
|
||||
right: [],
|
||||
@@ -87,30 +98,35 @@ class EditSightStore {
|
||||
}
|
||||
|
||||
hasLoadedCommon = false;
|
||||
isLoading = false;
|
||||
|
||||
getSightInfo = async (id: number, language: Language) => {
|
||||
const response = await languageInstance(language).get(`/sight/${id}`);
|
||||
const data = response.data;
|
||||
this.isLoading = true;
|
||||
try {
|
||||
const response = await languageInstance(language).get(`/sight/${id}`);
|
||||
const data = response.data;
|
||||
|
||||
if (data.left_article != 0 && data.left_article != null) {
|
||||
await this.getLeftArticle(data.left_article);
|
||||
}
|
||||
if (data.left_article != 0 && data.left_article != null) {
|
||||
await this.getLeftArticle(data.left_article);
|
||||
}
|
||||
|
||||
runInAction(() => {
|
||||
// Обновляем языковую часть
|
||||
this.sight[language] = {
|
||||
...this.sight[language],
|
||||
...data,
|
||||
};
|
||||
|
||||
// Только при первом запросе обновляем общую часть
|
||||
if (!this.hasLoadedCommon) {
|
||||
this.sight.common = {
|
||||
...this.sight.common,
|
||||
runInAction(() => {
|
||||
this.sight[language] = {
|
||||
...this.sight[language],
|
||||
...data,
|
||||
};
|
||||
this.hasLoadedCommon = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (!this.hasLoadedCommon) {
|
||||
this.sight.common = {
|
||||
...this.sight.common,
|
||||
...data,
|
||||
};
|
||||
this.hasLoadedCommon = true;
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
};
|
||||
|
||||
updateLeftInfo = (language: Language, heading: string, body: string) => {
|
||||
@@ -123,7 +139,6 @@ class EditSightStore {
|
||||
let responseEn = await languageInstance("en").get(`/sight/${id}/article`);
|
||||
let responseZh = await languageInstance("zh").get(`/sight/${id}/article`);
|
||||
|
||||
// Create a map of article IDs to their media
|
||||
const mediaMap = new Map();
|
||||
for (const article of responseRu.data) {
|
||||
const responseMedia = await authInstance.get(
|
||||
@@ -132,7 +147,6 @@ class EditSightStore {
|
||||
mediaMap.set(article.id, responseMedia.data);
|
||||
}
|
||||
|
||||
// Function to add media to articles
|
||||
const addMediaToArticles = (articles: any[]) => {
|
||||
return articles.map((article) => ({
|
||||
...article,
|
||||
@@ -173,6 +187,8 @@ class EditSightStore {
|
||||
|
||||
clearSightInfo = () => {
|
||||
this.needLeaveAgree = false;
|
||||
this.hasLoadedCommon = false;
|
||||
this.isLoading = false;
|
||||
this.sight = {
|
||||
common: {
|
||||
id: 0,
|
||||
@@ -180,7 +196,10 @@ class EditSightStore {
|
||||
city: "",
|
||||
latitude: 0,
|
||||
longitude: 0,
|
||||
is_default_icon: false,
|
||||
thumbnail: null,
|
||||
icon: null,
|
||||
alt_icon: null,
|
||||
watermark_lu: null,
|
||||
watermark_rd: null,
|
||||
left_article: 0,
|
||||
@@ -190,6 +209,7 @@ class EditSightStore {
|
||||
ru: {
|
||||
id: 0,
|
||||
name: "",
|
||||
short_name: "",
|
||||
address: "",
|
||||
left: { heading: "", body: "", media: [] },
|
||||
right: [],
|
||||
@@ -198,6 +218,7 @@ class EditSightStore {
|
||||
en: {
|
||||
id: 0,
|
||||
name: "",
|
||||
short_name: "",
|
||||
address: "",
|
||||
left: { heading: "", body: "", media: [] },
|
||||
right: [],
|
||||
@@ -206,6 +227,7 @@ class EditSightStore {
|
||||
zh: {
|
||||
id: 0,
|
||||
name: "",
|
||||
short_name: "",
|
||||
address: "",
|
||||
left: { heading: "", body: "", media: [] },
|
||||
right: [],
|
||||
@@ -249,6 +271,7 @@ class EditSightStore {
|
||||
const response = await languageInstance("ru").post(`/article`, {
|
||||
heading: this.sight.ru.left.heading,
|
||||
body: this.sight.ru.left.body,
|
||||
...(selectedCityStore.selectedCityId ? { city_id: selectedCityStore.selectedCityId } : {}),
|
||||
});
|
||||
createdLeftArticleId = response.data.id;
|
||||
await languageInstance("en").patch(`/article/${createdLeftArticleId}`, {
|
||||
@@ -286,9 +309,14 @@ class EditSightStore {
|
||||
...this.sight.common,
|
||||
translations: {
|
||||
name: {
|
||||
ru: this.sight.ru.name,
|
||||
en: this.sight.en.name,
|
||||
zh: this.sight.zh.name,
|
||||
ru: (this.sight.ru.name || "").trim(),
|
||||
en: (this.sight.en.name || "").trim(),
|
||||
zh: (this.sight.zh.name || "").trim(),
|
||||
},
|
||||
short_name: {
|
||||
ru: (this.sight.ru.short_name || "").trim(),
|
||||
en: (this.sight.en.short_name || "").trim(),
|
||||
zh: (this.sight.zh.short_name || "").trim(),
|
||||
},
|
||||
address: {
|
||||
ru: this.sight.ru.address,
|
||||
@@ -327,28 +355,6 @@ class EditSightStore {
|
||||
articles: articleIdsInObject,
|
||||
});
|
||||
|
||||
// await languageInstance("ru").patch(
|
||||
// `/sight/${this.sight.common.left_article}/article`,
|
||||
// {
|
||||
// heading: this.sight.ru.left.heading,
|
||||
// body: this.sight.ru.left.body,
|
||||
// }
|
||||
// );
|
||||
// await languageInstance("en").patch(
|
||||
// `/sight/${this.sight.common.left_article}/article`,
|
||||
// {
|
||||
// heading: this.sight.en.left.heading,
|
||||
// body: this.sight.en.left.body,
|
||||
// }
|
||||
// );
|
||||
// await languageInstance("zh").patch(
|
||||
// `/sight/${this.sight.common.left_article}/article`,
|
||||
// {
|
||||
// heading: this.sight.zh.left.heading,
|
||||
// body: this.sight.zh.left.body,
|
||||
// }
|
||||
// );
|
||||
|
||||
this.needLeaveAgree = false;
|
||||
};
|
||||
|
||||
@@ -400,16 +406,37 @@ class EditSightStore {
|
||||
};
|
||||
|
||||
createLeftArticle = async () => {
|
||||
const ruName = (this.sight.ru.name || "").trim();
|
||||
const enName = (this.sight.en.name || "").trim();
|
||||
const zhName = (this.sight.zh.name || "").trim();
|
||||
const hasAnyName = !!(ruName || enName || zhName);
|
||||
|
||||
const response = await languageInstance("ru").post(`/article`, {
|
||||
heading: "",
|
||||
heading: hasAnyName ? ruName : "",
|
||||
body: "",
|
||||
...(selectedCityStore.selectedCityId ? { city_id: selectedCityStore.selectedCityId } : {}),
|
||||
});
|
||||
|
||||
this.sight.common.left_article = response.data.id;
|
||||
|
||||
this.sight.ru.left.heading = "";
|
||||
this.sight.en.left.heading = "";
|
||||
this.sight.zh.left.heading = "";
|
||||
await languageInstance("en").patch(
|
||||
`/article/${this.sight.common.left_article}`,
|
||||
{
|
||||
heading: hasAnyName ? enName : "",
|
||||
body: "",
|
||||
}
|
||||
);
|
||||
await languageInstance("zh").patch(
|
||||
`/article/${this.sight.common.left_article}`,
|
||||
{
|
||||
heading: hasAnyName ? zhName : "",
|
||||
body: "",
|
||||
}
|
||||
);
|
||||
|
||||
this.sight.ru.left.heading = hasAnyName ? ruName : "";
|
||||
this.sight.en.left.heading = hasAnyName ? enName : "";
|
||||
this.sight.zh.left.heading = hasAnyName ? zhName : "";
|
||||
this.sight.ru.left.body = "";
|
||||
this.sight.en.left.body = "";
|
||||
this.sight.zh.left.body = "";
|
||||
@@ -486,18 +513,22 @@ class EditSightStore {
|
||||
formData.append("media_name", media_name);
|
||||
}
|
||||
formData.append("type", type.toString());
|
||||
try {
|
||||
const response = await authInstance.post(`/media`, formData);
|
||||
this.fileToUpload = null;
|
||||
this.uploadMediaOpen = false;
|
||||
mediaStore.getMedia();
|
||||
return {
|
||||
id: response.data.id,
|
||||
filename: filename,
|
||||
media_name: media_name,
|
||||
media_type: type,
|
||||
};
|
||||
} catch (error) {}
|
||||
if (selectedCityStore.selectedCityId) {
|
||||
formData.append("city_id", selectedCityStore.selectedCityId.toString());
|
||||
}
|
||||
|
||||
const response = await authInstance.post(`/media`, formData);
|
||||
this.fileToUpload = null;
|
||||
this.uploadMediaOpen = false;
|
||||
|
||||
mediaStore.getMedia();
|
||||
|
||||
return {
|
||||
id: response.data.id,
|
||||
filename: filename,
|
||||
media_name: media_name,
|
||||
media_type: type,
|
||||
};
|
||||
};
|
||||
|
||||
createLinkWithArticle = async (media: {
|
||||
@@ -569,7 +600,7 @@ class EditSightStore {
|
||||
});
|
||||
});
|
||||
|
||||
return article_id; // Return the linked article ID
|
||||
return article_id;
|
||||
};
|
||||
|
||||
deleteRightArticleMedia = async (article_id: number, media_id: string) => {
|
||||
@@ -627,6 +658,7 @@ class EditSightStore {
|
||||
zh: articleZhData.body,
|
||||
},
|
||||
},
|
||||
...(selectedCityStore.selectedCityId ? { city_id: selectedCityStore.selectedCityId } : {}),
|
||||
});
|
||||
const { id } = articleId.data;
|
||||
|
||||
@@ -675,7 +707,7 @@ class EditSightStore {
|
||||
});
|
||||
});
|
||||
|
||||
return id; // Return the ID of the newly created article
|
||||
return id;
|
||||
};
|
||||
|
||||
createLinkWithRightArticle = async (
|
||||
@@ -750,7 +782,7 @@ class EditSightStore {
|
||||
|
||||
this.sight.ru.right = sortArticles(this.sight.ru.right);
|
||||
this.sight.en.right = sortArticles(this.sight.en.right);
|
||||
this.sight.zh.right = sortArticles(this.sight.zh.right); // теперь zh тоже сортируется одинаково
|
||||
this.sight.zh.right = sortArticles(this.sight.zh.right);
|
||||
|
||||
this.needLeaveAgree = true;
|
||||
};
|
||||
|
||||
@@ -6,10 +6,24 @@ class LanguageStore {
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this);
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
const storedLanguage = window.localStorage.getItem("appLanguage");
|
||||
if (
|
||||
storedLanguage &&
|
||||
["ru", "en", "zh"].includes(storedLanguage.toLowerCase())
|
||||
) {
|
||||
this.language = storedLanguage.toLowerCase() as Language;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setLanguage = (language: Language) => {
|
||||
this.language = language;
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.localStorage.setItem("appLanguage", language);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ export type Media = {
|
||||
filename: string;
|
||||
media_name: string;
|
||||
media_type: number;
|
||||
city_id?: number | null;
|
||||
};
|
||||
|
||||
class MediaStore {
|
||||
@@ -39,12 +40,11 @@ class MediaStore {
|
||||
updateMedia = async (id: string, data: Partial<Media>) => {
|
||||
const response = await authInstance.patch(`/media/${id}`, data);
|
||||
runInAction(() => {
|
||||
// Update in media array
|
||||
const index = this.media.findIndex((m) => m.id === id);
|
||||
if (index !== -1) {
|
||||
this.media[index] = { ...this.media[index], ...response.data };
|
||||
}
|
||||
// Update oneMedia if it's the current media being viewed
|
||||
|
||||
if (this.oneMedia?.id === id) {
|
||||
this.oneMedia = { ...this.oneMedia, ...response.data };
|
||||
}
|
||||
@@ -64,12 +64,11 @@ class MediaStore {
|
||||
});
|
||||
|
||||
runInAction(() => {
|
||||
// Update in media array
|
||||
const index = this.media.findIndex((m) => m.id === id);
|
||||
if (index !== -1) {
|
||||
this.media[index] = { ...this.media[index], ...response.data };
|
||||
}
|
||||
// Update oneMedia if it's the current media being viewed
|
||||
|
||||
if (this.oneMedia?.id === id) {
|
||||
this.oneMedia = { ...this.oneMedia, ...response.data };
|
||||
}
|
||||
@@ -77,10 +76,11 @@ class MediaStore {
|
||||
return response.data;
|
||||
};
|
||||
|
||||
createMedia = async (name: string, type: string) => {
|
||||
createMedia = async (name: string, type: string, cityId?: number | null) => {
|
||||
const response = await authInstance.post("/media", {
|
||||
media_name: name,
|
||||
media_type: type,
|
||||
...(cityId ? { city_id: cityId } : {}),
|
||||
});
|
||||
runInAction(() => {
|
||||
this.media.push(response.data);
|
||||
|
||||
@@ -15,7 +15,6 @@ class ModelLoadingStore {
|
||||
makeAutoObservable(this);
|
||||
}
|
||||
|
||||
// Начать отслеживание загрузки модели
|
||||
startLoading(modelId: string) {
|
||||
this.loadingStates.set(modelId, {
|
||||
isLoading: true,
|
||||
@@ -25,7 +24,6 @@ class ModelLoadingStore {
|
||||
});
|
||||
}
|
||||
|
||||
// Обновить прогресс загрузки
|
||||
updateProgress(modelId: string, progress: number) {
|
||||
const state = this.loadingStates.get(modelId);
|
||||
if (state) {
|
||||
@@ -33,7 +31,6 @@ class ModelLoadingStore {
|
||||
}
|
||||
}
|
||||
|
||||
// Завершить загрузку модели
|
||||
finishLoading(modelId: string) {
|
||||
const state = this.loadingStates.get(modelId);
|
||||
if (state) {
|
||||
@@ -42,12 +39,10 @@ class ModelLoadingStore {
|
||||
}
|
||||
}
|
||||
|
||||
// Остановить загрузку (в случае ошибки)
|
||||
stopLoading(modelId: string) {
|
||||
this.loadingStates.delete(modelId);
|
||||
}
|
||||
|
||||
// Обработать ошибку загрузки
|
||||
handleError(modelId: string, error?: string) {
|
||||
const state = this.loadingStates.get(modelId);
|
||||
if (state) {
|
||||
@@ -56,26 +51,22 @@ class ModelLoadingStore {
|
||||
}
|
||||
}
|
||||
|
||||
// Получить состояние загрузки для конкретной модели
|
||||
getLoadingState(modelId: string): ModelLoadingState | undefined {
|
||||
return this.loadingStates.get(modelId);
|
||||
}
|
||||
|
||||
// Проверить, загружается ли какая-либо модель
|
||||
get isAnyModelLoading(): boolean {
|
||||
return Array.from(this.loadingStates.values()).some(
|
||||
(state) => state.isLoading
|
||||
);
|
||||
}
|
||||
|
||||
// Получить все загружающиеся модели
|
||||
get loadingModels(): ModelLoadingState[] {
|
||||
return Array.from(this.loadingStates.values()).filter(
|
||||
(state) => state.isLoading
|
||||
);
|
||||
}
|
||||
|
||||
// Получить общий прогресс всех загружающихся моделей
|
||||
get overallProgress(): number {
|
||||
const loadingModels = this.loadingModels;
|
||||
if (loadingModels.length === 0) return 100;
|
||||
@@ -87,12 +78,10 @@ class ModelLoadingStore {
|
||||
return Math.round(totalProgress / loadingModels.length);
|
||||
}
|
||||
|
||||
// Проверить, заблокировано ли сохранение (есть ли загружающиеся модели)
|
||||
get isSaveBlocked(): boolean {
|
||||
return this.isAnyModelLoading;
|
||||
}
|
||||
|
||||
// Очистить все состояния загрузки
|
||||
clearAll() {
|
||||
this.loadingStates.clear();
|
||||
}
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
import { makeAutoObservable, runInAction } from "mobx";
|
||||
import { authInstance } from "@shared";
|
||||
import {
|
||||
authInstance,
|
||||
languageInstance,
|
||||
languageStore,
|
||||
isMediaIdEmpty,
|
||||
} from "@shared";
|
||||
|
||||
export type Route = {
|
||||
route_name: string;
|
||||
carrier: string;
|
||||
carrier_id: number;
|
||||
center_latitude: number;
|
||||
center_longitude: number;
|
||||
governor_appeal: number;
|
||||
button_text?: string;
|
||||
id: number;
|
||||
icon: string;
|
||||
path: number[][];
|
||||
rotate: number;
|
||||
route_direction: boolean;
|
||||
@@ -16,6 +24,7 @@ export type Route = {
|
||||
scale_max: number;
|
||||
scale_min: number;
|
||||
video_preview: string;
|
||||
video_timer: number;
|
||||
};
|
||||
|
||||
class RouteStore {
|
||||
@@ -45,7 +54,7 @@ class RouteStore {
|
||||
});
|
||||
};
|
||||
|
||||
createRoute = async (route: any) => {
|
||||
createRoute = async (route: any): Promise<number> => {
|
||||
const response = await authInstance.post("/route", route);
|
||||
const id = response.data.id;
|
||||
|
||||
@@ -53,6 +62,8 @@ class RouteStore {
|
||||
this.route[id] = { ...route, id };
|
||||
this.routes.data = [...this.routes.data, { ...route, id }];
|
||||
});
|
||||
|
||||
return id;
|
||||
};
|
||||
|
||||
deleteRoute = async (id: number) => {
|
||||
@@ -88,21 +99,54 @@ class RouteStore {
|
||||
};
|
||||
|
||||
saveRouteStations = async (routeId: number, stationId: number) => {
|
||||
await authInstance.patch(`/route/${routeId}/station`, {
|
||||
...this.routeStations[routeId]?.find(
|
||||
(station) => station.id === stationId
|
||||
),
|
||||
const { language } = languageStore;
|
||||
|
||||
const stationResponse = await languageInstance(language).get(
|
||||
`/station/${stationId}`
|
||||
);
|
||||
const fullStationData = stationResponse.data;
|
||||
|
||||
// Получаем отредактированные данные из локального кеша
|
||||
const editedStationData = this.routeStations[routeId]?.find(
|
||||
(station) => station.id === stationId
|
||||
);
|
||||
|
||||
const dataToSend: any = {
|
||||
station_id: stationId,
|
||||
offset_x: editedStationData?.offset_x ?? fullStationData.offset_x ?? 0,
|
||||
offset_y: editedStationData?.offset_y ?? fullStationData.offset_y ?? 0,
|
||||
align: editedStationData?.align ?? fullStationData.align ?? 0,
|
||||
transfers: fullStationData.transfers || {},
|
||||
};
|
||||
|
||||
await authInstance.patch(`/route/${routeId}/station`, dataToSend);
|
||||
|
||||
// Обновляем локальный кеш после успешного сохранения
|
||||
runInAction(() => {
|
||||
if (this.routeStations[routeId]) {
|
||||
this.routeStations[routeId] = this.routeStations[routeId].map(
|
||||
(station) =>
|
||||
station.id === stationId
|
||||
? {
|
||||
...station,
|
||||
...dataToSend,
|
||||
}
|
||||
: station
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
editRouteData = {
|
||||
route_name: "",
|
||||
carrier: "",
|
||||
carrier_id: 0,
|
||||
center_latitude: "",
|
||||
center_longitude: "",
|
||||
governor_appeal: 0,
|
||||
button_text: "" as string | undefined,
|
||||
id: 0,
|
||||
icon: "",
|
||||
path: [] as number[][],
|
||||
rotate: 0,
|
||||
route_direction: false,
|
||||
@@ -110,7 +154,8 @@ class RouteStore {
|
||||
route_sys_number: "",
|
||||
scale_max: 0,
|
||||
scale_min: 0,
|
||||
video_preview: "",
|
||||
video_preview: "" as string | undefined,
|
||||
video_timer: 420,
|
||||
};
|
||||
|
||||
setEditRouteData = (data: any) => {
|
||||
@@ -118,11 +163,30 @@ class RouteStore {
|
||||
};
|
||||
|
||||
editRoute = async (id: number) => {
|
||||
const response = await authInstance.patch(`/route/${id}`, {
|
||||
if (
|
||||
!this.editRouteData.video_preview ||
|
||||
isMediaIdEmpty(this.editRouteData.video_preview)
|
||||
) {
|
||||
delete this.editRouteData.video_preview;
|
||||
}
|
||||
if (!this.editRouteData.icon || isMediaIdEmpty(this.editRouteData.icon)) {
|
||||
delete (this.editRouteData as any).icon;
|
||||
}
|
||||
const dataToSend: any = {
|
||||
...this.editRouteData,
|
||||
route_name: (this.editRouteData.route_name || "").trim(),
|
||||
route_number: (this.editRouteData.route_number || "").trim(),
|
||||
route_sys_number: (this.editRouteData.route_sys_number || "").trim(),
|
||||
center_latitude: parseFloat(this.editRouteData.center_latitude),
|
||||
center_longitude: parseFloat(this.editRouteData.center_longitude),
|
||||
});
|
||||
};
|
||||
if (
|
||||
this.editRouteData.governor_appeal === 0 ||
|
||||
!this.editRouteData.governor_appeal
|
||||
) {
|
||||
dataToSend.governor_appeal = null;
|
||||
}
|
||||
const response = await authInstance.patch(`/route/${id}`, dataToSend);
|
||||
|
||||
runInAction(() => {
|
||||
this.route[id] = response.data;
|
||||
@@ -140,6 +204,49 @@ class RouteStore {
|
||||
});
|
||||
};
|
||||
|
||||
sightCounts: Map<number, number> = new Map();
|
||||
stationCounts: Map<number, number> = new Map();
|
||||
countsLoading = false;
|
||||
|
||||
loadCounts = async (routeIds: number[]) => {
|
||||
if (routeIds.length === 0) return;
|
||||
|
||||
runInAction(() => {
|
||||
this.countsLoading = true;
|
||||
});
|
||||
|
||||
const batchSize = 20;
|
||||
for (let i = 0; i < routeIds.length; i += batchSize) {
|
||||
const batch = routeIds.slice(i, i + batchSize);
|
||||
const results = await Promise.allSettled(
|
||||
batch.flatMap((id) => [
|
||||
authInstance.get(`/route/${id}/sight/count`).then((res) => ({ id, type: "sight", data: res.data })),
|
||||
authInstance.get(`/route/${id}/station/count`).then((res) => ({ id, type: "station", data: res.data })),
|
||||
])
|
||||
);
|
||||
|
||||
runInAction(() => {
|
||||
for (const result of results) {
|
||||
if (result.status === "fulfilled") {
|
||||
const { id, type, data } = result.value;
|
||||
let count = 0;
|
||||
if (typeof data === "number") {
|
||||
count = data;
|
||||
} else if (data && typeof data === "object") {
|
||||
count = Object.values(data).reduce((sum: number, v: any) => sum + (Number(v) || 0), 0);
|
||||
}
|
||||
if (type === "sight") this.sightCounts.set(id, count);
|
||||
else this.stationCounts.set(id, count);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
runInAction(() => {
|
||||
this.countsLoading = false;
|
||||
});
|
||||
};
|
||||
|
||||
selectedStationId = 0;
|
||||
|
||||
setSelectedStationId = (id: number) => {
|
||||
|
||||
@@ -3,6 +3,8 @@ import { City } from "../CityStore";
|
||||
|
||||
class SelectedCityStore {
|
||||
selectedCity: City | null = null;
|
||||
isLocked: boolean = false;
|
||||
cityVersion: number = 0;
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this);
|
||||
@@ -24,6 +26,7 @@ class SelectedCityStore {
|
||||
setSelectedCity = (city: City | null) => {
|
||||
runInAction(() => {
|
||||
this.selectedCity = city;
|
||||
this.cityVersion += 1;
|
||||
if (city) {
|
||||
localStorage.setItem("selectedCity", JSON.stringify(city));
|
||||
} else {
|
||||
@@ -32,6 +35,12 @@ class SelectedCityStore {
|
||||
});
|
||||
};
|
||||
|
||||
setIsLocked = (locked: boolean) => {
|
||||
runInAction(() => {
|
||||
this.isLocked = locked;
|
||||
});
|
||||
};
|
||||
|
||||
clearSelectedCity = () => {
|
||||
this.setSelectedCity(null);
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@ export type Language = "ru" | "en" | "zh";
|
||||
export type MultilingualContent = {
|
||||
[key in Language]: {
|
||||
name: string;
|
||||
short_name: string;
|
||||
address: string;
|
||||
};
|
||||
};
|
||||
@@ -18,12 +19,16 @@ export type MultilingualContent = {
|
||||
export type Sight = {
|
||||
id: number;
|
||||
name: string;
|
||||
short_name: string | null;
|
||||
city_id: number;
|
||||
city: string;
|
||||
address: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
is_default_icon: boolean;
|
||||
thumbnail: string | null;
|
||||
icon: string | null;
|
||||
alt_icon: string | null;
|
||||
watermark_lu: string | null;
|
||||
watermark_rd: string | null;
|
||||
left_article: number;
|
||||
@@ -34,6 +39,7 @@ export type Sight = {
|
||||
export type CreateSight = {
|
||||
[key in Language]: {
|
||||
name: string;
|
||||
short_name: string;
|
||||
address: string;
|
||||
};
|
||||
};
|
||||
@@ -42,9 +48,9 @@ class SightsStore {
|
||||
sights: Sight[] = [];
|
||||
sight: Sight | null = null;
|
||||
createSight: CreateSight = {
|
||||
ru: { name: "", address: "" },
|
||||
en: { name: "", address: "" },
|
||||
zh: { name: "", address: "" },
|
||||
ru: { name: "", short_name: "", address: "" },
|
||||
en: { name: "", short_name: "", address: "" },
|
||||
zh: { name: "", short_name: "", address: "" },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
@@ -58,47 +64,13 @@ class SightsStore {
|
||||
});
|
||||
};
|
||||
|
||||
// getSight = async (id: number) => {
|
||||
// const response = await authInstance.get(`/sight/${id}`);
|
||||
|
||||
// runInAction(() => {
|
||||
// this.sight = response.data;
|
||||
// editSightStore.sightInfo = {
|
||||
// ...editSightStore.sightInfo,
|
||||
// id: response.data.id,
|
||||
// city_id: response.data.city_id,
|
||||
// city: response.data.city,
|
||||
// latitude: response.data.latitude,
|
||||
// longitude: response.data.longitude,
|
||||
// thumbnail: response.data.thumbnail,
|
||||
// watermark_lu: response.data.watermark_lu,
|
||||
// watermark_rd: response.data.watermark_rd,
|
||||
// left_article: response.data.left_article,
|
||||
// preview_media: response.data.preview_media,
|
||||
// video_preview: response.data.video_preview,
|
||||
|
||||
// [languageStore.language]: {
|
||||
// info: {
|
||||
// name: response.data.name,
|
||||
// address: response.data.address,
|
||||
// },
|
||||
// left: {
|
||||
// heading: articlesStore.articles[languageStore.language].find(
|
||||
// (article) => article.id === response.data.left_article
|
||||
// )?.heading,
|
||||
// body: articlesStore.articles[languageStore.language].find(
|
||||
// },
|
||||
// },
|
||||
// };
|
||||
// });
|
||||
// };
|
||||
|
||||
createSightAction = async (
|
||||
city: number,
|
||||
coordinates: { latitude: number; longitude: number }
|
||||
) => {
|
||||
const response = await authInstance.post("/sight", {
|
||||
name: this.createSight[languageStore.language].name,
|
||||
short_name: this.createSight[languageStore.language].short_name,
|
||||
address: this.createSight[languageStore.language].address,
|
||||
city_id: city,
|
||||
latitude: coordinates.latitude,
|
||||
@@ -119,6 +91,7 @@ class SightsStore {
|
||||
`/sight/${id}`,
|
||||
{
|
||||
name: this.createSight[anotherLanguages[0] as Language].name,
|
||||
short_name: this.createSight[anotherLanguages[0] as Language].short_name,
|
||||
address: this.createSight[anotherLanguages[0] as Language].address,
|
||||
city_id: city,
|
||||
latitude: coordinates.latitude,
|
||||
@@ -130,6 +103,7 @@ class SightsStore {
|
||||
`/sight/${id}`,
|
||||
{
|
||||
name: this.createSight[anotherLanguages[1] as Language].name,
|
||||
short_name: this.createSight[anotherLanguages[1] as Language].short_name,
|
||||
address: this.createSight[anotherLanguages[1] as Language].address,
|
||||
city_id: city,
|
||||
latitude: coordinates.latitude,
|
||||
@@ -139,9 +113,9 @@ class SightsStore {
|
||||
|
||||
runInAction(() => {
|
||||
this.createSight = {
|
||||
ru: { name: "", address: "" },
|
||||
en: { name: "", address: "" },
|
||||
zh: { name: "", address: "" },
|
||||
ru: { name: "", short_name: "", address: "" },
|
||||
en: { name: "", short_name: "", address: "" },
|
||||
zh: { name: "", short_name: "", address: "" },
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -188,14 +162,17 @@ class SightsStore {
|
||||
this.createSight = {
|
||||
ru: {
|
||||
name: "",
|
||||
short_name: "",
|
||||
address: "",
|
||||
},
|
||||
en: {
|
||||
name: "",
|
||||
short_name: "",
|
||||
address: "",
|
||||
},
|
||||
zh: {
|
||||
name: "",
|
||||
short_name: "",
|
||||
address: "",
|
||||
},
|
||||
};
|
||||
@@ -209,7 +186,10 @@ class SightsStore {
|
||||
city_id: this.sight?.city_id,
|
||||
latitude: this.sight?.latitude,
|
||||
longitude: this.sight?.longitude,
|
||||
is_default_icon: this.sight?.is_default_icon,
|
||||
thumbnail: this.sight?.thumbnail,
|
||||
icon: this.sight?.icon,
|
||||
alt_icon: this.sight?.alt_icon,
|
||||
watermark_lu: this.sight?.watermark_lu,
|
||||
watermark_rd: this.sight?.watermark_rd,
|
||||
left_article: this.sight?.left_article,
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { authInstance } from "@shared";
|
||||
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { makeAutoObservable, runInAction } from "mobx";
|
||||
// Импорт функции сброса кешей карты
|
||||
// import { clearMapCaches } from "../../pages/MapPage";
|
||||
import {
|
||||
articlesStore,
|
||||
cityStore,
|
||||
@@ -25,19 +23,71 @@ type Snapshot = {
|
||||
Name: string;
|
||||
ParentID: string;
|
||||
CreationTime: string;
|
||||
occupied_disk_space_gb: number;
|
||||
};
|
||||
|
||||
type SnapshotStatus = {
|
||||
ID: string;
|
||||
Status: string;
|
||||
Progress: number;
|
||||
Error: string;
|
||||
};
|
||||
|
||||
type StorageInfo = {
|
||||
available_disk_space_gb: number;
|
||||
total_disk_space_gb: number;
|
||||
};
|
||||
|
||||
class SnapshotStore {
|
||||
snapshots: Snapshot[] = [];
|
||||
snapshot: Snapshot | null = null;
|
||||
lastRequestId: string | null = null;
|
||||
snapshotStatus: SnapshotStatus | null = null;
|
||||
storageInfo: StorageInfo | null = null;
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this);
|
||||
}
|
||||
|
||||
// Функция для сброса всех кешей в приложении
|
||||
clearStoreCache = () => {
|
||||
runInAction(() => {
|
||||
articlesStore.articleList = {
|
||||
ru: { data: [], loaded: false },
|
||||
en: { data: [], loaded: false },
|
||||
zh: { data: [], loaded: false },
|
||||
};
|
||||
articlesStore.articlePreview = {};
|
||||
articlesStore.articleData = null;
|
||||
articlesStore.articleMedia = null;
|
||||
|
||||
carrierStore.carriers = {
|
||||
ru: { data: [], loaded: false },
|
||||
en: { data: [], loaded: false },
|
||||
zh: { data: [], loaded: false },
|
||||
};
|
||||
|
||||
stationsStore.stationLists = {
|
||||
ru: { data: [], loaded: false },
|
||||
en: { data: [], loaded: false },
|
||||
zh: { data: [], loaded: false },
|
||||
};
|
||||
stationsStore.stationPreview = {};
|
||||
|
||||
sightsStore.sights = [];
|
||||
sightsStore.sight = null;
|
||||
|
||||
routeStore.routes = { data: [], loaded: false };
|
||||
|
||||
vehicleStore.vehicles = { data: [], loaded: false };
|
||||
|
||||
userStore.users = { data: [], loaded: false };
|
||||
|
||||
mediaStore.media = [];
|
||||
mediaStore.oneMedia = null;
|
||||
});
|
||||
};
|
||||
|
||||
private clearAllCaches = () => {
|
||||
// Сброс кешей статей
|
||||
articlesStore.articleList = {
|
||||
ru: { data: [], loaded: false },
|
||||
en: { data: [], loaded: false },
|
||||
@@ -47,7 +97,6 @@ class SnapshotStore {
|
||||
articlesStore.articleData = null;
|
||||
articlesStore.articleMedia = null;
|
||||
|
||||
// Сброс кешей городов
|
||||
cityStore.cities = {
|
||||
ru: { data: [], loaded: false },
|
||||
en: { data: [], loaded: false },
|
||||
@@ -56,21 +105,18 @@ class SnapshotStore {
|
||||
cityStore.ruCities = { data: [], loaded: false };
|
||||
cityStore.city = {};
|
||||
|
||||
// Сброс кешей стран
|
||||
countryStore.countries = {
|
||||
ru: { data: [], loaded: false },
|
||||
en: { data: [], loaded: false },
|
||||
zh: { data: [], loaded: false },
|
||||
};
|
||||
|
||||
// Сброс кешей перевозчиков
|
||||
carrierStore.carriers = {
|
||||
ru: { data: [], loaded: false },
|
||||
en: { data: [], loaded: false },
|
||||
zh: { data: [], loaded: false },
|
||||
};
|
||||
|
||||
// Сброс кешей станций
|
||||
stationsStore.stationLists = {
|
||||
ru: { data: [], loaded: false },
|
||||
en: { data: [], loaded: false },
|
||||
@@ -78,31 +124,28 @@ class SnapshotStore {
|
||||
};
|
||||
stationsStore.stationPreview = {};
|
||||
|
||||
// Сброс кешей достопримечательностей
|
||||
sightsStore.sights = [];
|
||||
sightsStore.sight = null;
|
||||
|
||||
// Сброс кешей маршрутов
|
||||
routeStore.routes = { data: [], loaded: false };
|
||||
|
||||
// Сброс кешей транспорта
|
||||
vehicleStore.vehicles = { data: [], loaded: false };
|
||||
|
||||
// Сброс кешей пользователей
|
||||
userStore.users = { data: [], loaded: false };
|
||||
|
||||
// Сброс кешей медиа
|
||||
mediaStore.media = [];
|
||||
mediaStore.oneMedia = null;
|
||||
|
||||
// Сброс кешей создания и редактирования достопримечательностей
|
||||
createSightStore.sight = JSON.parse(
|
||||
JSON.stringify({
|
||||
city_id: 0,
|
||||
city: "",
|
||||
latitude: 0,
|
||||
longitude: 0,
|
||||
is_default_icon: false,
|
||||
thumbnail: null,
|
||||
icon: null,
|
||||
alt_icon: null,
|
||||
watermark_lu: null,
|
||||
watermark_rd: null,
|
||||
left_article: 0,
|
||||
@@ -139,7 +182,10 @@ class SnapshotStore {
|
||||
city: "",
|
||||
latitude: 0,
|
||||
longitude: 0,
|
||||
is_default_icon: false,
|
||||
thumbnail: null,
|
||||
icon: null,
|
||||
alt_icon: null,
|
||||
watermark_lu: null,
|
||||
watermark_rd: null,
|
||||
left_article: 0,
|
||||
@@ -149,6 +195,7 @@ class SnapshotStore {
|
||||
ru: {
|
||||
id: 0,
|
||||
name: "",
|
||||
short_name: "",
|
||||
address: "",
|
||||
left: { heading: "", body: "", media: [] },
|
||||
right: [],
|
||||
@@ -156,6 +203,7 @@ class SnapshotStore {
|
||||
en: {
|
||||
id: 0,
|
||||
name: "",
|
||||
short_name: "",
|
||||
address: "",
|
||||
left: { heading: "", body: "", media: [] },
|
||||
right: [],
|
||||
@@ -163,6 +211,7 @@ class SnapshotStore {
|
||||
zh: {
|
||||
id: 0,
|
||||
name: "",
|
||||
short_name: "",
|
||||
address: "",
|
||||
left: { heading: "", body: "", media: [] },
|
||||
right: [],
|
||||
@@ -173,26 +222,21 @@ class SnapshotStore {
|
||||
editSightStore.fileToUpload = null;
|
||||
editSightStore.needLeaveAgree = false;
|
||||
|
||||
// Сброс кешей устройств
|
||||
devicesStore.devices = [];
|
||||
devicesStore.uuid = null;
|
||||
devicesStore.sendSnapshotModalOpen = false;
|
||||
|
||||
// Сброс кешей авторизации (кроме токена)
|
||||
authStore.payload = null;
|
||||
authStore.error = null;
|
||||
authStore.isLoading = false;
|
||||
|
||||
// Сброс кешей карты (если они загружены)
|
||||
try {
|
||||
// Сбрасываем кеши mapStore если он доступен
|
||||
if (typeof window !== "undefined" && (window as any).mapStore) {
|
||||
(window as any).mapStore.routes = [];
|
||||
(window as any).mapStore.stations = [];
|
||||
(window as any).mapStore.sights = [];
|
||||
}
|
||||
|
||||
// Сбрасываем кеши MapService если он доступен
|
||||
if (typeof window !== "undefined" && (window as any).mapServiceInstance) {
|
||||
(window as any).mapServiceInstance.clearCaches();
|
||||
}
|
||||
@@ -200,7 +244,6 @@ class SnapshotStore {
|
||||
console.warn("Не удалось сбросить кеши карты:", error);
|
||||
}
|
||||
|
||||
// Сброс localStorage кешей (кроме токена авторизации)
|
||||
const token = localStorage.getItem("token");
|
||||
const rememberedEmail = localStorage.getItem("rememberedEmail");
|
||||
const rememberedPassword = localStorage.getItem("rememberedPassword");
|
||||
@@ -208,14 +251,12 @@ class SnapshotStore {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
|
||||
// Восстанавливаем важные данные
|
||||
if (token) localStorage.setItem("token", token);
|
||||
if (rememberedEmail)
|
||||
localStorage.setItem("rememberedEmail", rememberedEmail);
|
||||
if (rememberedPassword)
|
||||
localStorage.setItem("rememberedPassword", rememberedPassword);
|
||||
|
||||
// Сброс кешей карты (если они есть)
|
||||
const mapPositionKey = "mapPosition";
|
||||
const activeSectionKey = "mapActiveSection";
|
||||
if (localStorage.getItem(mapPositionKey)) {
|
||||
@@ -225,7 +266,6 @@ class SnapshotStore {
|
||||
localStorage.removeItem(activeSectionKey);
|
||||
}
|
||||
|
||||
// Попытка очистить кеш браузера (если поддерживается)
|
||||
if ("caches" in window) {
|
||||
try {
|
||||
caches.keys().then((cacheNames) => {
|
||||
@@ -240,7 +280,6 @@ class SnapshotStore {
|
||||
}
|
||||
}
|
||||
|
||||
// Попытка очистить IndexedDB (если поддерживается)
|
||||
if ("indexedDB" in window) {
|
||||
try {
|
||||
indexedDB.databases().then((databases) => {
|
||||
@@ -268,10 +307,17 @@ class SnapshotStore {
|
||||
};
|
||||
|
||||
deleteSnapshot = async (id: string) => {
|
||||
const snapshot = this.snapshots.find((s) => s.ID === id);
|
||||
await authInstance.delete(`/snapshots/${id}`);
|
||||
|
||||
runInAction(() => {
|
||||
this.snapshots = this.snapshots.filter((snapshot) => snapshot.ID !== id);
|
||||
this.snapshots = this.snapshots.filter((s) => s.ID !== id);
|
||||
if (this.storageInfo && snapshot?.occupied_disk_space_gb) {
|
||||
this.storageInfo = {
|
||||
...this.storageInfo,
|
||||
available_disk_space_gb: this.storageInfo.available_disk_space_gb + snapshot.occupied_disk_space_gb,
|
||||
};
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -284,15 +330,43 @@ class SnapshotStore {
|
||||
};
|
||||
|
||||
restoreSnapshot = async (id: string) => {
|
||||
// Сначала сбрасываем все кеши
|
||||
this.clearAllCaches();
|
||||
|
||||
// Затем восстанавливаем снапшот
|
||||
await authInstance.post(`/snapshots/${id}/restore`);
|
||||
};
|
||||
|
||||
createEmptySnapshot = async (name: string) => {
|
||||
await authInstance.post(`/snapshots/empty`, {
|
||||
name: name.trim(),
|
||||
});
|
||||
};
|
||||
|
||||
createSnapshot = async (name: string) => {
|
||||
await authInstance.post(`/snapshots`, { name });
|
||||
this.lastRequestId = uuidv4();
|
||||
|
||||
const response = await authInstance.post(
|
||||
`/snapshots`,
|
||||
{ name: name.trim() },
|
||||
{ headers: { "X-Request-ID": this.lastRequestId } }
|
||||
);
|
||||
|
||||
return response.data.ID;
|
||||
};
|
||||
|
||||
getSnapshotStatus = async (id: string) => {
|
||||
const response = await authInstance.get(`/snapshots/status/${id}`);
|
||||
|
||||
runInAction(() => {
|
||||
this.snapshotStatus = response.data;
|
||||
});
|
||||
};
|
||||
|
||||
getStorageInfo = async () => {
|
||||
const response = await authInstance.get(`/snapshots/storage`);
|
||||
|
||||
runInAction(() => {
|
||||
this.storageInfo = response.data;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { authInstance, languageInstance, languageStore } from "@shared";
|
||||
import { makeAutoObservable, runInAction } from "mobx";
|
||||
import { routeStore } from "../RouteStore";
|
||||
|
||||
type Language = "ru" | "en" | "zh";
|
||||
|
||||
@@ -7,12 +8,11 @@ type StationLanguageData = {
|
||||
name: string;
|
||||
system_name: string;
|
||||
address: string;
|
||||
loaded: boolean; // Indicates if this language's data has been loaded/modified
|
||||
loaded: boolean;
|
||||
};
|
||||
|
||||
type StationCommonData = {
|
||||
city_id: number;
|
||||
direction: boolean;
|
||||
description: string;
|
||||
icon: string;
|
||||
latitude: number;
|
||||
@@ -43,7 +43,6 @@ type Station = {
|
||||
city: string;
|
||||
city_id: number;
|
||||
description: string;
|
||||
direction: boolean;
|
||||
icon: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
@@ -72,6 +71,9 @@ class StationsStore {
|
||||
stations: Station[] = [];
|
||||
station: Station | null = null;
|
||||
|
||||
sightCounts: Map<number, number> = new Map();
|
||||
sightCountsLoading = false;
|
||||
|
||||
stationLists: {
|
||||
[key in Language]: {
|
||||
data: Station[];
|
||||
@@ -92,7 +94,6 @@ class StationsStore {
|
||||
},
|
||||
};
|
||||
|
||||
// This will store the full station data, keyed by ID and then by language
|
||||
stationPreview: Record<
|
||||
string,
|
||||
Record<string, { loaded: boolean; data: Station }>
|
||||
@@ -123,7 +124,6 @@ class StationsStore {
|
||||
common: {
|
||||
city: "",
|
||||
city_id: 0,
|
||||
direction: false,
|
||||
description: "",
|
||||
icon: "",
|
||||
latitude: 0,
|
||||
@@ -169,7 +169,6 @@ class StationsStore {
|
||||
common: {
|
||||
city: "",
|
||||
city_id: 0,
|
||||
direction: false,
|
||||
description: "",
|
||||
icon: "",
|
||||
latitude: 0,
|
||||
@@ -215,6 +214,45 @@ class StationsStore {
|
||||
});
|
||||
};
|
||||
|
||||
loadSightCounts = async (stationIds: number[]) => {
|
||||
if (stationIds.length === 0) return;
|
||||
|
||||
runInAction(() => {
|
||||
this.sightCountsLoading = true;
|
||||
});
|
||||
|
||||
const batchSize = 20;
|
||||
for (let i = 0; i < stationIds.length; i += batchSize) {
|
||||
const batch = stationIds.slice(i, i + batchSize);
|
||||
const results = await Promise.allSettled(
|
||||
batch.map((id) =>
|
||||
authInstance
|
||||
.get(`/station/${id}/sight/count`)
|
||||
.then((res) => ({ id, data: res.data }))
|
||||
)
|
||||
);
|
||||
|
||||
runInAction(() => {
|
||||
for (const result of results) {
|
||||
if (result.status === "fulfilled") {
|
||||
const { id, data } = result.value;
|
||||
let count = 0;
|
||||
if (typeof data === "number") {
|
||||
count = data;
|
||||
} else if (data && typeof data === "object") {
|
||||
count = Object.values(data).reduce((sum: number, v: any) => sum + (Number(v) || 0), 0);
|
||||
}
|
||||
this.sightCounts.set(id, count);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
runInAction(() => {
|
||||
this.sightCountsLoading = false;
|
||||
});
|
||||
};
|
||||
|
||||
setEditCommonData = (data: Partial<StationCommonData>) => {
|
||||
this.editStationData.common = {
|
||||
...this.editStationData.common,
|
||||
@@ -252,7 +290,6 @@ class StationsStore {
|
||||
common: {
|
||||
city: ruResponse.data.city,
|
||||
city_id: ruResponse.data.city_id,
|
||||
direction: ruResponse.data.direction,
|
||||
description: ruResponse.data.description,
|
||||
icon: ruResponse.data.icon,
|
||||
latitude: ruResponse.data.latitude,
|
||||
@@ -264,7 +301,6 @@ class StationsStore {
|
||||
};
|
||||
};
|
||||
|
||||
// Sets language-specific station data
|
||||
setLanguageEditStationData = (
|
||||
language: Language,
|
||||
data: Partial<StationLanguageData>
|
||||
@@ -278,7 +314,6 @@ class StationsStore {
|
||||
editStation = async (id: number) => {
|
||||
const commonDataPayload = {
|
||||
city_id: this.editStationData.common.city_id,
|
||||
direction: this.editStationData.common.direction,
|
||||
icon: this.editStationData.common.icon,
|
||||
latitude: this.editStationData.common.latitude,
|
||||
longitude: this.editStationData.common.longitude,
|
||||
@@ -294,16 +329,15 @@ class StationsStore {
|
||||
const response = await languageInstance(language).patch(
|
||||
`/station/${id}`,
|
||||
{
|
||||
name: name || "",
|
||||
system_name: name || "", // system_name is often derived from name
|
||||
description: description || "",
|
||||
address: address || "",
|
||||
name: (name || "").trim(),
|
||||
system_name: (name || "").trim(),
|
||||
description: (description || "").trim(),
|
||||
address: (address || "").trim(),
|
||||
...commonDataPayload,
|
||||
}
|
||||
);
|
||||
|
||||
runInAction(() => {
|
||||
// Update the cached preview data and station lists after successful patch
|
||||
if (this.stationPreview[id]) {
|
||||
this.stationPreview[id][language] = {
|
||||
loaded: true,
|
||||
@@ -343,11 +377,11 @@ class StationsStore {
|
||||
|
||||
runInAction(() => {
|
||||
this.stations = this.stations.filter((station) => station.id !== id);
|
||||
// Also clear from stationPreview cache
|
||||
|
||||
if (this.stationPreview[id]) {
|
||||
delete this.stationPreview[id];
|
||||
}
|
||||
// Clear from stationLists as well for all languages
|
||||
|
||||
for (const lang of ["ru", "en", "zh"] as const) {
|
||||
if (this.stationLists[lang].data) {
|
||||
this.stationLists[lang].data = this.stationLists[lang].data.filter(
|
||||
@@ -407,7 +441,6 @@ class StationsStore {
|
||||
const { language } = languageStore;
|
||||
let commonDataPayload: Partial<StationCommonData> = {
|
||||
city_id: this.createStationData.common.city_id,
|
||||
direction: this.createStationData.common.direction,
|
||||
icon: this.createStationData.common.icon,
|
||||
latitude: this.createStationData.common.latitude,
|
||||
longitude: this.createStationData.common.longitude,
|
||||
@@ -421,14 +454,13 @@ class StationsStore {
|
||||
delete commonDataPayload.icon;
|
||||
}
|
||||
|
||||
// First create station in Russian
|
||||
const { name, address } = this.createStationData[language];
|
||||
const description = this.createStationData.common.description;
|
||||
const response = await languageInstance(language).post("/station", {
|
||||
name: name || "",
|
||||
system_name: name || "", // system_name is often derived from name
|
||||
description: description || "",
|
||||
address: address || "",
|
||||
name: (name || "").trim(),
|
||||
system_name: (name || "").trim(),
|
||||
description: (description || "").trim(),
|
||||
address: (address || "").trim(),
|
||||
...commonDataPayload,
|
||||
});
|
||||
|
||||
@@ -436,9 +468,8 @@ class StationsStore {
|
||||
this.stationLists[language].data.push(response.data);
|
||||
});
|
||||
|
||||
const stationId = response.data.id;
|
||||
const stationId: number = response.data.id;
|
||||
|
||||
// Then update for other languages
|
||||
for (const lang of ["ru", "en", "zh"].filter(
|
||||
(lang) => lang !== language
|
||||
) as Language[]) {
|
||||
@@ -447,10 +478,10 @@ class StationsStore {
|
||||
const response = await languageInstance(lang).patch(
|
||||
`/station/${stationId}`,
|
||||
{
|
||||
name: name || "",
|
||||
system_name: name || "", // system_name is often derived from name
|
||||
description: description || "",
|
||||
address: address || "",
|
||||
name: (name || "").trim(),
|
||||
system_name: (name || "").trim(),
|
||||
description: (description || "").trim(),
|
||||
address: (address || "").trim(),
|
||||
...commonDataPayload,
|
||||
}
|
||||
);
|
||||
@@ -483,7 +514,6 @@ class StationsStore {
|
||||
common: {
|
||||
city: "",
|
||||
city_id: 0,
|
||||
direction: false,
|
||||
icon: "",
|
||||
latitude: 0,
|
||||
description: "",
|
||||
@@ -507,7 +537,6 @@ class StationsStore {
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// Reset editStationData when navigating away or after saving
|
||||
resetEditStationData = () => {
|
||||
this.editStationData = {
|
||||
ru: {
|
||||
@@ -531,7 +560,6 @@ class StationsStore {
|
||||
common: {
|
||||
city: "",
|
||||
city_id: 0,
|
||||
direction: false,
|
||||
description: "",
|
||||
icon: "",
|
||||
latitude: 0,
|
||||
@@ -552,6 +580,97 @@ class StationsStore {
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
updateStationTransfers = async (
|
||||
id: number,
|
||||
transfers: {
|
||||
bus: string;
|
||||
metro_blue: string;
|
||||
metro_green: string;
|
||||
metro_orange: string;
|
||||
metro_purple: string;
|
||||
metro_red: string;
|
||||
train: string;
|
||||
tram: string;
|
||||
trolleybus: string;
|
||||
}
|
||||
) => {
|
||||
const { language } = languageStore;
|
||||
|
||||
const response = await languageInstance(language).get(`/station/${id}`);
|
||||
const stationData = response.data as Station;
|
||||
|
||||
if (!stationData) {
|
||||
throw new Error("Station not found");
|
||||
}
|
||||
|
||||
// Формируем commonDataPayload как в editStation, с обновленными transfers
|
||||
const commonDataPayload = {
|
||||
city_id: stationData.city_id,
|
||||
latitude: stationData.latitude,
|
||||
longitude: stationData.longitude,
|
||||
offset_x: stationData.offset_x,
|
||||
offset_y: stationData.offset_y,
|
||||
transfers: transfers,
|
||||
city: stationData.city || "",
|
||||
};
|
||||
|
||||
// Отправляем один PATCH запрос, так как пересадки общие для всех языков
|
||||
const patchResponse = await languageInstance(language).patch(
|
||||
`/station/${id}`,
|
||||
{
|
||||
name: stationData.name || "",
|
||||
system_name: stationData.system_name || "",
|
||||
description: stationData.description || "",
|
||||
address: stationData.address || "",
|
||||
...commonDataPayload,
|
||||
}
|
||||
);
|
||||
|
||||
// Обновляем данные для всех языков в локальном состоянии
|
||||
runInAction(() => {
|
||||
const updatedTransfers = patchResponse.data.transfers;
|
||||
|
||||
for (const lang of ["ru", "en", "zh"] as const) {
|
||||
if (this.stationPreview[id]) {
|
||||
this.stationPreview[id][lang] = {
|
||||
...this.stationPreview[id][lang],
|
||||
data: {
|
||||
...this.stationPreview[id][lang].data,
|
||||
transfers: updatedTransfers,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (this.stationLists[lang].data) {
|
||||
this.stationLists[lang].data = this.stationLists[lang].data.map(
|
||||
(station: Station) =>
|
||||
station.id === id
|
||||
? {
|
||||
...station,
|
||||
transfers: updatedTransfers,
|
||||
}
|
||||
: station
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Обновляем пересадки в RouteStore.routeStations для всех маршрутов
|
||||
if (routeStore?.routeStations) {
|
||||
for (const routeId in routeStore.routeStations) {
|
||||
routeStore.routeStations[routeId] = routeStore.routeStations[
|
||||
routeId
|
||||
].map((station: any) =>
|
||||
station.id === id
|
||||
? {
|
||||
...station,
|
||||
transfers: updatedTransfers,
|
||||
}
|
||||
: station
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export const stationsStore = new StationsStore();
|
||||
|
||||
18
src/shared/store/TestingModeStore/api.ts
Normal file
18
src/shared/store/TestingModeStore/api.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { authInstance } from "@shared";
|
||||
|
||||
export type TestingModeResponse = {
|
||||
enabled: boolean;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export const getTestingModeApi = async (): Promise<TestingModeResponse> => {
|
||||
const response = await authInstance.get("/testing-mode");
|
||||
return response.data as TestingModeResponse;
|
||||
};
|
||||
|
||||
export const setTestingModeApi = async (request: {
|
||||
enabled: boolean;
|
||||
}): Promise<TestingModeResponse> => {
|
||||
const response = await authInstance.post("/testing-mode", request);
|
||||
return response.data as TestingModeResponse;
|
||||
};
|
||||
62
src/shared/store/TestingModeStore/index.ts
Normal file
62
src/shared/store/TestingModeStore/index.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { makeAutoObservable } from "mobx";
|
||||
import { mobxFetch } from "@shared";
|
||||
import {
|
||||
TestingModeResponse,
|
||||
getTestingModeApi,
|
||||
setTestingModeApi,
|
||||
} from "./api";
|
||||
|
||||
const POLLING_INTERVAL = 10_000;
|
||||
|
||||
class TestingModeStore {
|
||||
testingMode: TestingModeResponse | null = null;
|
||||
testingModeLoading = false;
|
||||
testingModeError: string | null = null;
|
||||
|
||||
setTestingModeResult: TestingModeResponse | null = null;
|
||||
setTestingModeLoading = false;
|
||||
setTestingModeError: string | null = null;
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this);
|
||||
}
|
||||
|
||||
get isEnabled(): boolean {
|
||||
return this.testingMode?.enabled ?? false;
|
||||
}
|
||||
|
||||
fetchTestingModeAction = mobxFetch<TestingModeResponse, TestingModeStore>({
|
||||
store: this,
|
||||
value: "testingMode",
|
||||
loading: "testingModeLoading",
|
||||
error: "testingModeError",
|
||||
fn: getTestingModeApi,
|
||||
pollingInterval: POLLING_INTERVAL,
|
||||
});
|
||||
|
||||
setTestingModeAction = mobxFetch<
|
||||
{ enabled: boolean },
|
||||
TestingModeResponse,
|
||||
TestingModeStore
|
||||
>({
|
||||
store: this,
|
||||
value: "setTestingModeResult",
|
||||
loading: "setTestingModeLoading",
|
||||
error: "setTestingModeError",
|
||||
fn: setTestingModeApi,
|
||||
onSuccess: (result) => {
|
||||
this.testingMode = result;
|
||||
},
|
||||
});
|
||||
|
||||
startPolling() {
|
||||
this.fetchTestingModeAction();
|
||||
}
|
||||
|
||||
stopPolling() {
|
||||
this.fetchTestingModeAction.stopPolling?.();
|
||||
}
|
||||
}
|
||||
|
||||
export const testingModeStore = new TestingModeStore();
|
||||
export { TestingModeStore };
|
||||
14
src/shared/store/UserStore/api.ts
Normal file
14
src/shared/store/UserStore/api.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { authInstance } from "@shared";
|
||||
import { User } from "./index";
|
||||
|
||||
export const addUserCityApi = async (
|
||||
{ id, city_ids }: { id: number; city_ids: number[] },
|
||||
signal?: AbortSignal,
|
||||
): Promise<User> => {
|
||||
const response = await authInstance.patch(
|
||||
`/user/${id}/city`,
|
||||
{ city_ids },
|
||||
{ signal },
|
||||
);
|
||||
return response.data as User;
|
||||
};
|
||||
@@ -1,5 +1,11 @@
|
||||
import { authInstance } from "@shared";
|
||||
import { authInstance, mobxFetch } from "@shared";
|
||||
import { makeAutoObservable, runInAction } from "mobx";
|
||||
import { addUserCityApi } from "./api";
|
||||
|
||||
export type UserCity = {
|
||||
city_id: number;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type User = {
|
||||
id: number;
|
||||
@@ -7,6 +13,9 @@ export type User = {
|
||||
is_admin: boolean;
|
||||
name: string;
|
||||
password?: string;
|
||||
icon?: string;
|
||||
roles?: string[];
|
||||
cities?: UserCity[];
|
||||
};
|
||||
|
||||
class UserStore {
|
||||
@@ -57,15 +66,25 @@ class UserStore {
|
||||
email: "",
|
||||
password: "",
|
||||
is_admin: false,
|
||||
icon: "",
|
||||
roles: ["articles_ro", "articles_rw", "media_ro", "media_rw"],
|
||||
};
|
||||
|
||||
setCreateUserData = (
|
||||
name: string,
|
||||
email: string,
|
||||
password: string,
|
||||
is_admin: boolean
|
||||
is_admin: boolean,
|
||||
icon?: string,
|
||||
) => {
|
||||
this.createUserData = { name, email, password, is_admin };
|
||||
this.createUserData = {
|
||||
...this.createUserData,
|
||||
name,
|
||||
email,
|
||||
password,
|
||||
is_admin,
|
||||
icon: icon ?? "",
|
||||
};
|
||||
};
|
||||
|
||||
createUser = async () => {
|
||||
@@ -73,7 +92,19 @@ class UserStore {
|
||||
if (this.users.data.length > 0) {
|
||||
id = this.users.data[this.users.data.length - 1].id + 1;
|
||||
}
|
||||
const response = await authInstance.post("/user", this.createUserData);
|
||||
const payload: Partial<User> = {
|
||||
...this.createUserData,
|
||||
name: (this.createUserData.name || "").trim(),
|
||||
email: (this.createUserData.email || "").trim(),
|
||||
};
|
||||
const baseRoles = new Set<string>(payload.roles ?? []);
|
||||
baseRoles.add("articles_ro");
|
||||
baseRoles.add("articles_rw");
|
||||
baseRoles.add("media_ro");
|
||||
baseRoles.add("media_rw");
|
||||
payload.roles = Array.from(baseRoles);
|
||||
if (!payload.icon) delete payload.icon;
|
||||
const response = await authInstance.post("/user", payload);
|
||||
|
||||
runInAction(() => {
|
||||
this.users.data.push({
|
||||
@@ -88,27 +119,75 @@ class UserStore {
|
||||
email: "",
|
||||
password: "",
|
||||
is_admin: false,
|
||||
icon: "",
|
||||
roles: [],
|
||||
};
|
||||
|
||||
setEditUserData = (
|
||||
name: string,
|
||||
email: string,
|
||||
password: string,
|
||||
is_admin: boolean
|
||||
is_admin: boolean,
|
||||
icon?: string,
|
||||
) => {
|
||||
this.editUserData = { name, email, password, is_admin };
|
||||
this.editUserData = {
|
||||
...this.editUserData,
|
||||
name,
|
||||
email,
|
||||
password,
|
||||
is_admin,
|
||||
icon: icon ?? "",
|
||||
};
|
||||
};
|
||||
|
||||
setEditUserRoles = (roles: string[]) => {
|
||||
this.editUserData = { ...this.editUserData, roles };
|
||||
};
|
||||
|
||||
editUser = async (id: number) => {
|
||||
const response = await authInstance.patch(`/user/${id}`, this.editUserData);
|
||||
const payload = {
|
||||
...this.editUserData,
|
||||
name: (this.editUserData.name || "").trim(),
|
||||
email: (this.editUserData.email || "").trim(),
|
||||
};
|
||||
if (!payload.icon) delete payload.icon;
|
||||
if (!payload.password?.trim()) delete payload.password;
|
||||
|
||||
const response = await authInstance.patch(`/user/${id}`, payload);
|
||||
|
||||
runInAction(() => {
|
||||
this.users.data = this.users.data.map((user) =>
|
||||
user.id === id ? { ...user, ...response.data } : user
|
||||
user.id === id ? { ...user, ...response.data } : user,
|
||||
);
|
||||
this.user[id] = { ...this.user[id], ...response.data };
|
||||
});
|
||||
};
|
||||
|
||||
addUserCityResult: User | null = null;
|
||||
addUserCityLoading = false;
|
||||
addUserCityError: string | null = null;
|
||||
|
||||
addUserCityAction = mobxFetch<
|
||||
{ id: number; city_ids: number[] },
|
||||
User,
|
||||
UserStore
|
||||
>({
|
||||
store: this,
|
||||
value: "addUserCityResult",
|
||||
loading: "addUserCityLoading",
|
||||
error: "addUserCityError",
|
||||
fn: addUserCityApi,
|
||||
onSuccess: (result) => {
|
||||
runInAction(() => {
|
||||
this.users.data = this.users.data.map((user) =>
|
||||
user.id === result.id ? { ...user, ...result } : user,
|
||||
);
|
||||
if (this.user[result.id]) {
|
||||
this.user[result.id] = { ...this.user[result.id], ...result };
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export const userStore = new UserStore();
|
||||
|
||||
13
src/shared/store/VehicleStore/api.ts
Normal file
13
src/shared/store/VehicleStore/api.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { languageInstance } from "@shared";
|
||||
import { VehicleMaintenanceSession } from "./types";
|
||||
|
||||
export const getVehicleSessionsApi = async (
|
||||
id: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<VehicleMaintenanceSession[]> => {
|
||||
const response = await languageInstance("ru").get(`/vehicle/${id}/sessions`, {
|
||||
signal,
|
||||
});
|
||||
|
||||
return Array.isArray(response.data) ? response.data : [];
|
||||
};
|
||||
@@ -1,24 +1,9 @@
|
||||
import { languageInstance } from "@shared";
|
||||
import { authInstance, languageInstance, mobxFetch } from "@shared";
|
||||
import { makeAutoObservable, runInAction } from "mobx";
|
||||
import { getVehicleSessionsApi } from "./api";
|
||||
import { Vehicle, VehicleMaintenanceSession } from "./types";
|
||||
|
||||
export type Vehicle = {
|
||||
vehicle: {
|
||||
id: number;
|
||||
tail_number: number;
|
||||
type: number;
|
||||
carrier_id: number;
|
||||
carrier: string;
|
||||
uuid?: string;
|
||||
};
|
||||
device_status?: {
|
||||
device_uuid: string;
|
||||
online: boolean;
|
||||
gps_ok: boolean;
|
||||
media_service_ok: boolean;
|
||||
last_update: string;
|
||||
is_connected: boolean;
|
||||
};
|
||||
};
|
||||
export type { Vehicle, VehicleMaintenanceSession } from "./types";
|
||||
|
||||
class VehicleStore {
|
||||
vehicles: {
|
||||
@@ -29,16 +14,83 @@ class VehicleStore {
|
||||
loaded: false,
|
||||
};
|
||||
vehicle: Record<string, Vehicle> = {};
|
||||
vehicleSessions: VehicleMaintenanceSession[] | null = null;
|
||||
vehicleSessionsLoading = false;
|
||||
vehicleSessionsError: string | null = null;
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this);
|
||||
}
|
||||
|
||||
private normalizeVehicleItem = (item: any): Vehicle => {
|
||||
if (item && typeof item === "object" && "vehicle" in item) {
|
||||
return {
|
||||
vehicle: item.vehicle ?? {},
|
||||
device_status: item.device_status,
|
||||
} as Vehicle;
|
||||
}
|
||||
|
||||
return {
|
||||
vehicle: item ?? {},
|
||||
} as Vehicle;
|
||||
};
|
||||
|
||||
private mergeVehicleInCaches = (updatedVehicle: any) => {
|
||||
if (!updatedVehicle) return;
|
||||
|
||||
const updatedId = updatedVehicle.id;
|
||||
const updatedUuid = updatedVehicle.uuid;
|
||||
|
||||
const mergeItem = (item: Vehicle): Vehicle => ({
|
||||
...item,
|
||||
vehicle: {
|
||||
...item.vehicle,
|
||||
...updatedVehicle,
|
||||
},
|
||||
});
|
||||
|
||||
this.vehicles.data = this.vehicles.data.map((item) => {
|
||||
const sameId = updatedId != null && item.vehicle.id === updatedId;
|
||||
const sameUuid =
|
||||
updatedUuid != null &&
|
||||
item.vehicle.uuid != null &&
|
||||
item.vehicle.uuid === updatedUuid;
|
||||
|
||||
if (!sameId && !sameUuid) return item;
|
||||
|
||||
return mergeItem(item);
|
||||
});
|
||||
|
||||
if (updatedId != null) {
|
||||
const existing = this.vehicle[updatedId];
|
||||
this.vehicle[updatedId] = existing
|
||||
? mergeItem(existing)
|
||||
: ({ vehicle: updatedVehicle } as Vehicle);
|
||||
return;
|
||||
}
|
||||
|
||||
if (updatedUuid != null) {
|
||||
const entry = Object.entries(this.vehicle).find(
|
||||
([, item]) => item.vehicle.uuid === updatedUuid,
|
||||
);
|
||||
|
||||
if (entry) {
|
||||
const [key, item] = entry;
|
||||
this.vehicle[key] = mergeItem(item);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
getVehicles = async () => {
|
||||
const response = await languageInstance("ru").get(`/vehicle`);
|
||||
const vehiclesList = Array.isArray(response.data)
|
||||
? response.data
|
||||
: Array.isArray(response.data?.vehicles)
|
||||
? response.data.vehicles
|
||||
: [];
|
||||
|
||||
runInAction(() => {
|
||||
this.vehicles.data = response.data;
|
||||
this.vehicles.data = vehiclesList.map(this.normalizeVehicleItem);
|
||||
this.vehicles.loaded = true;
|
||||
});
|
||||
};
|
||||
@@ -48,63 +100,74 @@ class VehicleStore {
|
||||
|
||||
runInAction(() => {
|
||||
this.vehicles.data = this.vehicles.data.filter(
|
||||
(vehicle) => vehicle.vehicle.id !== id
|
||||
(vehicle) => vehicle.vehicle.id !== id,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
getVehicle = async (id: number) => {
|
||||
const response = await languageInstance("ru").get(`/vehicle/${id}`);
|
||||
const normalizedVehicle = this.normalizeVehicleItem(response.data);
|
||||
|
||||
runInAction(() => {
|
||||
this.vehicle[id] = response.data;
|
||||
this.vehicle[id] = normalizedVehicle;
|
||||
});
|
||||
};
|
||||
|
||||
createVehicle = async (
|
||||
tailNumber: number,
|
||||
tailNumber: string,
|
||||
type: number,
|
||||
carrier: string,
|
||||
carrierId: number
|
||||
carrierId: number,
|
||||
model?: string,
|
||||
cityId?: number,
|
||||
) => {
|
||||
const response = await languageInstance("ru").post("/vehicle", {
|
||||
tail_number: tailNumber,
|
||||
const payload: Record<string, unknown> = {
|
||||
tail_number: tailNumber.trim(),
|
||||
type,
|
||||
carrier,
|
||||
carrier: carrier.trim(),
|
||||
carrier_id: carrierId,
|
||||
});
|
||||
};
|
||||
// TODO: когда будет бекенд — добавить model в payload и в ответ
|
||||
if (model != null && model !== "") payload.model = model;
|
||||
if (cityId != null) payload.city_id = cityId;
|
||||
const response = await languageInstance("ru").post("/vehicle", payload);
|
||||
const normalizedVehicle = this.normalizeVehicleItem(response.data);
|
||||
|
||||
runInAction(() => {
|
||||
this.vehicles.data.push({
|
||||
vehicle: {
|
||||
id: response.data.id,
|
||||
tail_number: response.data.tail_number,
|
||||
type: response.data.type,
|
||||
carrier_id: response.data.carrier_id,
|
||||
carrier: response.data.carrier,
|
||||
uuid: response.data.uuid,
|
||||
},
|
||||
});
|
||||
this.vehicles.data.push(normalizedVehicle);
|
||||
if (normalizedVehicle.vehicle?.id != null) {
|
||||
this.vehicle[normalizedVehicle.vehicle.id] = normalizedVehicle;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
editVehicleData: {
|
||||
tail_number: number;
|
||||
tail_number: string;
|
||||
type: number;
|
||||
carrier: string;
|
||||
carrier_id: number;
|
||||
model: string;
|
||||
snapshot_update_blocked: boolean;
|
||||
city_id?: number;
|
||||
} = {
|
||||
tail_number: 0,
|
||||
tail_number: "",
|
||||
type: 0,
|
||||
carrier: "",
|
||||
carrier_id: 0,
|
||||
model: "",
|
||||
snapshot_update_blocked: false,
|
||||
city_id: undefined,
|
||||
};
|
||||
|
||||
setEditVehicleData = (data: {
|
||||
tail_number: number;
|
||||
tail_number: string;
|
||||
type: number;
|
||||
carrier: string;
|
||||
carrier_id: number;
|
||||
model?: string;
|
||||
snapshot_update_blocked?: boolean;
|
||||
city_id?: number;
|
||||
}) => {
|
||||
this.editVehicleData = {
|
||||
...this.editVehicleData,
|
||||
@@ -115,36 +178,93 @@ class VehicleStore {
|
||||
editVehicle = async (
|
||||
id: number,
|
||||
data: {
|
||||
tail_number: number;
|
||||
tail_number: string;
|
||||
type: number;
|
||||
carrier: string;
|
||||
carrier_id: number;
|
||||
}
|
||||
model?: string;
|
||||
snapshot_update_blocked?: boolean;
|
||||
city_id?: number;
|
||||
},
|
||||
) => {
|
||||
const response = await languageInstance("ru").patch(`/vehicle/${id}`, {
|
||||
tail_number: data.tail_number,
|
||||
const payload: Record<string, unknown> = {
|
||||
tail_number: data.tail_number.trim(),
|
||||
type: data.type,
|
||||
carrier: data.carrier,
|
||||
carrier: data.carrier.trim(),
|
||||
carrier_id: data.carrier_id,
|
||||
});
|
||||
};
|
||||
if (data.model != null && data.model !== "") payload.model = data.model;
|
||||
if (data.snapshot_update_blocked != null)
|
||||
payload.snapshot_update_blocked = data.snapshot_update_blocked;
|
||||
if (data.city_id != null) payload.city_id = data.city_id;
|
||||
const response = await languageInstance("ru").patch(
|
||||
`/vehicle/${id}`,
|
||||
payload,
|
||||
);
|
||||
const normalizedVehicle = this.normalizeVehicleItem(response.data);
|
||||
const updatedVehiclePayload = {
|
||||
...normalizedVehicle.vehicle,
|
||||
model: normalizedVehicle.vehicle.model ?? data.model,
|
||||
snapshot_update_blocked:
|
||||
normalizedVehicle.vehicle.snapshot_update_blocked ??
|
||||
data.snapshot_update_blocked,
|
||||
};
|
||||
|
||||
runInAction(() => {
|
||||
this.vehicle[id] = {
|
||||
vehicle: {
|
||||
...this.vehicle[id].vehicle,
|
||||
...response.data,
|
||||
},
|
||||
};
|
||||
this.vehicles.data = this.vehicles.data.map((vehicle) =>
|
||||
vehicle.vehicle.id === id
|
||||
? {
|
||||
...vehicle,
|
||||
...response.data,
|
||||
}
|
||||
: vehicle
|
||||
);
|
||||
this.mergeVehicleInCaches({
|
||||
...updatedVehiclePayload,
|
||||
id,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
setMaintenanceMode = async (uuid: string, enabled: boolean) => {
|
||||
const response = await authInstance.post(
|
||||
`/devices/${uuid}/maintenance-mode`,
|
||||
{
|
||||
enabled,
|
||||
},
|
||||
);
|
||||
const normalizedVehicle = this.normalizeVehicleItem(response.data);
|
||||
|
||||
runInAction(() => {
|
||||
this.mergeVehicleInCaches({
|
||||
...normalizedVehicle.vehicle,
|
||||
uuid,
|
||||
maintenance_mode_on:
|
||||
normalizedVehicle.vehicle.maintenance_mode_on ?? enabled,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
setDemoMode = async (uuid: string, enabled: boolean) => {
|
||||
const response = await authInstance.post(`/devices/${uuid}/demo-mode`, {
|
||||
enabled,
|
||||
});
|
||||
const normalizedVehicle = this.normalizeVehicleItem(response.data);
|
||||
|
||||
runInAction(() => {
|
||||
this.mergeVehicleInCaches({
|
||||
...normalizedVehicle.vehicle,
|
||||
uuid,
|
||||
demo_mode_enabled:
|
||||
normalizedVehicle.vehicle.demo_mode_enabled ?? enabled,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
getVehicleSessions = mobxFetch<
|
||||
number,
|
||||
VehicleMaintenanceSession[],
|
||||
VehicleStore
|
||||
>({
|
||||
store: this,
|
||||
value: "vehicleSessions",
|
||||
loading: "vehicleSessionsLoading",
|
||||
error: "vehicleSessionsError",
|
||||
resetValue: true,
|
||||
fn: getVehicleSessionsApi,
|
||||
});
|
||||
}
|
||||
|
||||
export const vehicleStore = new VehicleStore();
|
||||
|
||||
33
src/shared/store/VehicleStore/types.ts
Normal file
33
src/shared/store/VehicleStore/types.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
export type Vehicle = {
|
||||
vehicle: {
|
||||
id: number;
|
||||
tail_number: string;
|
||||
type: number;
|
||||
carrier_id: number;
|
||||
carrier: string;
|
||||
uuid?: string;
|
||||
model?: string;
|
||||
current_snapshot_uuid?: string;
|
||||
snapshot_update_blocked?: boolean;
|
||||
demo_mode_enabled?: boolean;
|
||||
maintenance_mode_on?: boolean;
|
||||
city_id?: number;
|
||||
};
|
||||
device_status?: {
|
||||
device_uuid: string;
|
||||
online: boolean;
|
||||
gps_ok: boolean;
|
||||
media_service_ok: boolean;
|
||||
last_update: string;
|
||||
is_connected: boolean;
|
||||
current_route_id?: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type VehicleMaintenanceSession = {
|
||||
duration_seconds: number;
|
||||
ended_at: string;
|
||||
id: number;
|
||||
started_at: string;
|
||||
vehicle_id: number;
|
||||
};
|
||||
@@ -16,3 +16,4 @@ export * from "./CarrierStore";
|
||||
export * from "./StationsStore";
|
||||
export * from "./MenuStore";
|
||||
export * from "./SelectedCityStore";
|
||||
export * from "./TestingModeStore";
|
||||
|
||||
171
src/shared/ui/AnimatedCircleButton.tsx
Normal file
171
src/shared/ui/AnimatedCircleButton.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import { forwardRef } from "react";
|
||||
import { Button, ButtonProps, CircularProgress } from "@mui/material";
|
||||
import { alpha, keyframes, styled } from "@mui/material/styles";
|
||||
import type { Theme } from "@mui/material/styles";
|
||||
|
||||
type AnimatedCircleButtonProps = ButtonProps & {
|
||||
disableAnimation?: boolean;
|
||||
loading?: boolean;
|
||||
};
|
||||
|
||||
type StyledButtonProps = AnimatedCircleButtonProps & { theme: Theme };
|
||||
|
||||
const loadingPulse = keyframes`
|
||||
0% {
|
||||
transform: translate(-50%, -50%) scale(0.6);
|
||||
opacity: 0.35;
|
||||
}
|
||||
50% {
|
||||
transform: translate(-50%, -50%) scale(1.45);
|
||||
opacity: 0.15;
|
||||
}
|
||||
100% {
|
||||
transform: translate(-50%, -50%) scale(0.6);
|
||||
opacity: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledButton = styled(Button, {
|
||||
shouldForwardProp: (prop) =>
|
||||
prop !== "disableAnimation" && prop !== "loading",
|
||||
})<AnimatedCircleButtonProps>((props: StyledButtonProps) => {
|
||||
const {
|
||||
theme,
|
||||
disableAnimation = false,
|
||||
color,
|
||||
variant = "text",
|
||||
disabled = false,
|
||||
loading = false,
|
||||
} = props;
|
||||
|
||||
const shouldAnimate = !disableAnimation && (!disabled || loading);
|
||||
const pointerBlocked = loading;
|
||||
|
||||
const paletteMainMap: Record<string, string> = {
|
||||
primary: theme.palette.primary.main,
|
||||
secondary: theme.palette.secondary.main,
|
||||
error: theme.palette.error.main,
|
||||
warning: theme.palette.warning.main,
|
||||
info: theme.palette.info.main,
|
||||
success: theme.palette.success.main,
|
||||
inherit: theme.palette.primary.main,
|
||||
};
|
||||
|
||||
const paletteMain =
|
||||
(color && paletteMainMap[String(color)]) ?? theme.palette.primary.main;
|
||||
|
||||
const pulseColor =
|
||||
variant === "outlined" || variant === "text"
|
||||
? alpha(paletteMain, 0.18)
|
||||
: alpha(paletteMain, 0.3);
|
||||
|
||||
return {
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
borderRadius: 5,
|
||||
zIndex: 0,
|
||||
transition: "transform 0.2s ease, box-shadow 0.2s ease",
|
||||
pointerEvents: pointerBlocked ? "none" : undefined,
|
||||
"&::after": shouldAnimate
|
||||
? {
|
||||
content: '""',
|
||||
position: "absolute",
|
||||
width: "12px",
|
||||
height: "12px",
|
||||
backgroundColor: pulseColor,
|
||||
borderRadius: "50%",
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
pointerEvents: "none",
|
||||
zIndex: 0,
|
||||
...(loading
|
||||
? {
|
||||
opacity: 0.35,
|
||||
transform: "translate(-50%, -50%) scale(0.6)",
|
||||
animation: `${loadingPulse} 1.2s ease-in-out infinite`,
|
||||
}
|
||||
: {
|
||||
opacity: 0,
|
||||
transform: "translate(-50%, -50%) scale(0)",
|
||||
transition: "transform 0.45s ease, opacity 0.45s ease",
|
||||
}),
|
||||
}
|
||||
: {},
|
||||
...(loading
|
||||
? {}
|
||||
: {
|
||||
"&:hover": {
|
||||
transform: "translateY(-1px)",
|
||||
boxShadow: theme.shadows[4],
|
||||
},
|
||||
"&:hover::after": shouldAnimate
|
||||
? {
|
||||
transform: "translate(-50%, -50%) scale(15)",
|
||||
opacity: 1,
|
||||
}
|
||||
: {},
|
||||
"&:active": {
|
||||
transform: "translateY(0)",
|
||||
boxShadow: theme.shadows[2],
|
||||
},
|
||||
"&:active::after": shouldAnimate
|
||||
? {
|
||||
transform: "translate(-50%, -50%) scale(18)",
|
||||
opacity: 0.4,
|
||||
}
|
||||
: {},
|
||||
}),
|
||||
"&.Mui-disabled": {
|
||||
boxShadow: "none",
|
||||
transform: "none",
|
||||
...(loading && shouldAnimate
|
||||
? {}
|
||||
: {
|
||||
"&::after": {
|
||||
opacity: 0,
|
||||
},
|
||||
}),
|
||||
},
|
||||
...(disabled && {
|
||||
boxShadow: "none",
|
||||
transform: "none",
|
||||
}),
|
||||
"& > *": {
|
||||
position: "relative",
|
||||
zIndex: 1,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export const AnimatedCircleButton = forwardRef<
|
||||
HTMLButtonElement,
|
||||
AnimatedCircleButtonProps
|
||||
>((props, ref) => {
|
||||
const {
|
||||
loading = false,
|
||||
disabled,
|
||||
children,
|
||||
startIcon,
|
||||
endIcon,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
const effectiveStartIcon = loading ? (
|
||||
<CircularProgress size={16} color="inherit" />
|
||||
) : (
|
||||
startIcon
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledButton
|
||||
ref={ref}
|
||||
loading={loading}
|
||||
disabled={loading ? true : disabled}
|
||||
startIcon={effectiveStartIcon}
|
||||
endIcon={loading ? undefined : endIcon}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</StyledButton>
|
||||
);
|
||||
});
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Modal as MuiModal, Typography, Box } from "@mui/material";
|
||||
import { Modal as MuiModal, Typography, Box, SxProps, Theme } from "@mui/material";
|
||||
|
||||
interface ModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
title?: string;
|
||||
sx?: SxProps<Theme>;
|
||||
}
|
||||
|
||||
const style = {
|
||||
@@ -19,7 +20,7 @@ const style = {
|
||||
borderRadius: 2,
|
||||
};
|
||||
|
||||
export const Modal = ({ open, onClose, children, title }: ModalProps) => {
|
||||
export const Modal = ({ open, onClose, children, title, sx }: ModalProps) => {
|
||||
return (
|
||||
<MuiModal
|
||||
open={open}
|
||||
@@ -27,7 +28,7 @@ export const Modal = ({ open, onClose, children, title }: ModalProps) => {
|
||||
aria-labelledby="modal-modal-title"
|
||||
aria-describedby="modal-modal-description"
|
||||
>
|
||||
<Box sx={style}>
|
||||
<Box sx={{ ...style, ...sx }}>
|
||||
{title && (
|
||||
<Typography
|
||||
id="modal-modal-title"
|
||||
|
||||
95
src/shared/ui/MultiSelect/index.tsx
Normal file
95
src/shared/ui/MultiSelect/index.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import {
|
||||
Autocomplete,
|
||||
Checkbox,
|
||||
CircularProgress,
|
||||
TextField,
|
||||
} from "@mui/material";
|
||||
import CheckBoxOutlineBlankIcon from "@mui/icons-material/CheckBoxOutlineBlank";
|
||||
import CheckBoxIcon from "@mui/icons-material/CheckBox";
|
||||
|
||||
export interface MultiSelectOption<TValue = number | string> {
|
||||
readonly value: TValue;
|
||||
readonly label: string;
|
||||
}
|
||||
|
||||
interface MultiSelectProps<TValue = number | string> {
|
||||
readonly options: MultiSelectOption<TValue>[];
|
||||
readonly value: TValue[];
|
||||
readonly onChange: (values: TValue[]) => void;
|
||||
readonly label?: string;
|
||||
readonly placeholder?: string;
|
||||
readonly loading?: boolean;
|
||||
readonly disabled?: boolean;
|
||||
readonly error?: boolean;
|
||||
readonly helperText?: string;
|
||||
readonly size?: "small" | "medium";
|
||||
readonly fullWidth?: boolean;
|
||||
}
|
||||
|
||||
export function MultiSelect<TValue = number | string>({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
placeholder,
|
||||
loading = false,
|
||||
disabled = false,
|
||||
error = false,
|
||||
helperText,
|
||||
size = "small",
|
||||
fullWidth = true,
|
||||
}: MultiSelectProps<TValue>) {
|
||||
const selectedOptions = options.filter((opt) => value.includes(opt.value));
|
||||
|
||||
return (
|
||||
<Autocomplete
|
||||
multiple
|
||||
disableCloseOnSelect
|
||||
fullWidth={fullWidth}
|
||||
size={size}
|
||||
disabled={disabled}
|
||||
loading={loading}
|
||||
options={options}
|
||||
value={selectedOptions}
|
||||
getOptionLabel={(option) => option.label}
|
||||
isOptionEqualToValue={(option, selected) => option.value === selected.value}
|
||||
onChange={(_, newSelected) => {
|
||||
onChange(newSelected.map((opt) => opt.value));
|
||||
}}
|
||||
renderOption={(props, option, { selected }) => {
|
||||
const { key, ...rest } = props as React.HTMLAttributes<HTMLLIElement> & { key: React.Key };
|
||||
return (
|
||||
<li key={key} {...rest}>
|
||||
<Checkbox
|
||||
icon={<CheckBoxOutlineBlankIcon fontSize="small" />}
|
||||
checkedIcon={<CheckBoxIcon fontSize="small" />}
|
||||
style={{ marginRight: 8 }}
|
||||
checked={selected}
|
||||
/>
|
||||
{option.label}
|
||||
</li>
|
||||
);
|
||||
}}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label={label}
|
||||
placeholder={selectedOptions.length === 0 ? placeholder : undefined}
|
||||
error={error}
|
||||
helperText={helperText}
|
||||
slotProps={{
|
||||
input: {
|
||||
...params.InputProps,
|
||||
endAdornment: (
|
||||
<>
|
||||
{loading && <CircularProgress color="inherit" size={16} />}
|
||||
{params.InputProps.endAdornment}
|
||||
</>
|
||||
),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
40
src/shared/ui/PreviewBackgroundToggle/index.tsx
Normal file
40
src/shared/ui/PreviewBackgroundToggle/index.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { IconButton, Tooltip } from "@mui/material";
|
||||
import { Contrast } from "lucide-react";
|
||||
|
||||
interface PreviewBackgroundToggleProps {
|
||||
dark: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
export const PreviewBackgroundToggle = ({
|
||||
dark,
|
||||
onToggle,
|
||||
}: PreviewBackgroundToggleProps) => (
|
||||
<Tooltip title={dark ? "Сделать фон белым" : "Сделать фон чёрным"}>
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label={dark ? "Сделать фон белым" : "Сделать фон чёрным"}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onToggle();
|
||||
}}
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
left: 8,
|
||||
zIndex: 2,
|
||||
color: dark ? "#fff" : "#000",
|
||||
backgroundColor: dark
|
||||
? "rgba(255, 255, 255, 0.16)"
|
||||
: "rgba(255, 255, 255, 0.8)",
|
||||
"&:hover": {
|
||||
backgroundColor: dark
|
||||
? "rgba(255, 255, 255, 0.28)"
|
||||
: "rgba(255, 255, 255, 1)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Contrast size={18} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
37
src/shared/ui/SearchInput/index.tsx
Normal file
37
src/shared/ui/SearchInput/index.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Search, X } from "lucide-react";
|
||||
|
||||
interface SearchInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export const SearchInput = ({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "Поиск...",
|
||||
}: SearchInputProps) => {
|
||||
return (
|
||||
<div className="relative mb-4 w-full max-w-sm">
|
||||
<Search
|
||||
size={16}
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="w-full pl-9 pr-8 py-2 border border-gray-300 rounded-md text-sm outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 bg-white"
|
||||
/>
|
||||
{value && (
|
||||
<button
|
||||
onClick={() => onChange("")}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -2,3 +2,8 @@ export * from "./TabPanel";
|
||||
export * from "./BackButton";
|
||||
export * from "./Modal";
|
||||
export * from "./CoordinatesInput";
|
||||
export * from "./AnimatedCircleButton";
|
||||
export * from "./LoadingSpinner";
|
||||
export * from "./MultiSelect";
|
||||
export * from "./SearchInput";
|
||||
export * from "./PreviewBackgroundToggle";
|
||||
|
||||
Reference in New Issue
Block a user