10 Commits

Author SHA1 Message Date
0a6192c7da feat: Корректировки 07.11.25 2025-11-11 09:13:58 +03:00
b1ba3b4cd5 feat: Update 2025-11-11 03:33:26 +03:00
1917b2cf5a fix: Delete ai comments 2025-11-06 00:58:10 +03:00
5298fb9f60 feat: Add description for stations in sightbar 2025-11-06 00:32:19 +03:00
c95a6517e9 fix: 01.11.25 MapPage update + sight/station relation + preview base 2025-11-06 00:21:45 +03:00
79f523e9cb hotfix-chunks (#17)
Co-authored-by: Микаэл Оганесян <mikaeloganesan@MacBook-Pro-Mikael.local>
Reviewed-on: #17
Reviewed-by: Илья Куприец <kkzemeow@gmail.com>
2025-10-31 14:25:46 +00:00
90f3d66b22 #14 Перепись редактирования и создания маршрута (#16)
Добавлено новое поле route_name:

Текстовые поля на двух страницах
Поле в списке маршрутов

Добавлено выбор видео на двух страниц вместе с редактором статей в виде модального окна

Модальное окно позволяет создать статью, выбрать готовую, отредактировать выбранную сразу на трех языках

Микаэл:

Пожалуйста, перепроверь код, вдруг чего найдешь как улучшить

+

захости локально и потыкай пж:

создай с 0 маршрут и прикрепи к нему созданную / какую-нибудь статью с видео, можешь попробовать загрузить либо взять готовое

после того как создашь, попробуй потыкать и поменять чего-нибудь

(проще обьясню: представь, что ты Руслан)

Reviewed-on: #16
Reviewed-by: Микаэл Оганесян <15lu.akari@unprism.ru>
Co-authored-by: fisenko <kkzemeow@gmail.com>
Co-committed-by: fisenko <kkzemeow@gmail.com>
2025-10-31 11:13:08 +00:00
2b48ade2f1 #13 Чистка, исправление кода
Reviewed-on: #15
Reviewed-by: Илья Куприец <kkzemeow@gmail.com>
2025-10-29 11:21:55 +00:00
b0fdf03cc6 fix: Fix icons in `top map sightbar 2025-10-29 14:19:00 +03:00
Микаэл Оганесян
349c7009c6 закрыл задачу 13 2025-10-29 02:32:14 +03:00
77 changed files with 10065 additions and 4678 deletions

View File

@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/favicon_ship.png" />
<link rel="icon" type="image/svg" href="/favicon_ship.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Белые ночи</title>
</head>

3409
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,7 @@
"private": true,
"version": "0.0.0",
"type": "module",
"license": "UNLICENSED",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
@@ -23,6 +24,7 @@
"@tailwindcss/vite": "^4.1.8",
"axios": "^1.9.0",
"easymde": "^2.20.0",
"i18n-iso-countries": "^7.14.0",
"lucide-react": "^0.511.0",
"mobx": "^6.13.7",
"mobx-react-lite": "^4.1.0",
@@ -30,11 +32,10 @@
"path": "^0.12.7",
"pixi.js": "^8.10.1",
"react": "^19.1.0",
"react-colorful": "^5.6.1",
"react-dom": "^19.1.0",
"react-dropzone": "^14.3.8",
"react-markdown": "^10.1.0",
"react-photo-sphere-viewer": "^6.2.3",
"react-router": "^7.9.4",
"react-router-dom": "^7.6.1",
"react-simplemde-editor": "^5.2.0",
"react-toastify": "^11.0.5",
@@ -53,9 +54,11 @@
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.19",
"globals": "^16.0.0",
"rollup-plugin-visualizer": "^6.0.5",
"typescript": "~5.8.3",
"typescript-eslint": "^8.30.1",
"vite": "^6.3.5"
"vite": "^6.3.5",
"vite-plugin-svgr": "^4.5.0"
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 750 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

3
public/favicon_ship.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.2 KiB

21
public/sight_icon.svg Normal file
View File

@@ -0,0 +1,21 @@
<svg width="53" height="49" viewBox="0 0 53 49" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 19.9296H52.7174V15.9968L26.3662 0L0 15.9968V19.9296ZM26.3659 3.75713L4.75331 16.8616H47.9636L26.3659 3.75713Z" fill="#A6A6A6"/>
<path d="M52.7174 45.4072H0V48.3587H52.7174V45.4072Z" fill="#A6A6A6"/>
<path d="M50.0742 41.4756H2.64355V44.427H50.0742V41.4756Z" fill="#A6A6A6"/>
<path d="M9.46312 21.6035H5.49805V39.0244H9.46312V21.6035Z" fill="#A6A6A6"/>
<path d="M11.4448 39.4316H3.51465V40.5827H11.4448V39.4316Z" fill="#A6A6A6"/>
<path d="M4.40104 20.6592C4.10066 20.6592 3.86035 20.8953 3.86035 21.1904C3.86035 21.4856 4.10066 21.7217 4.40104 21.7217C4.70143 21.7217 4.94173 21.4856 4.94173 21.1904H10.0182C10.0182 21.4856 10.2585 21.7217 10.5589 21.7217C10.8593 21.7217 11.0996 21.4856 11.0996 21.1904C11.0996 20.8953 10.8593 20.6592 10.5589 20.6592H4.40104Z" fill="#A6A6A6"/>
<path d="M22.0979 21.6035H18.1328V39.0244H22.0979V21.6035Z" fill="#A6A6A6"/>
<path d="M24.0815 39.4316H16.1514V40.5827H24.0815V39.4316Z" fill="#A6A6A6"/>
<path d="M17.0358 20.6592C16.7354 20.6592 16.4951 20.8953 16.4951 21.1904C16.4951 21.4856 16.7354 21.7217 17.0358 21.7217C17.3362 21.7217 17.5765 21.4856 17.5765 21.1904H22.653C22.653 21.4856 22.8933 21.7217 23.1937 21.7217C23.4941 21.7217 23.7344 21.4856 23.7344 21.1904C23.7344 20.8953 23.4941 20.6592 23.1937 20.6592H17.0358Z" fill="#A6A6A6"/>
<path d="M34.7414 21.6035H30.7764V39.0244H34.7414V21.6035Z" fill="#A6A6A6"/>
<path d="M36.7231 39.4316H28.793V40.5827H36.7231V39.4316Z" fill="#A6A6A6"/>
<path d="M29.6794 20.6592C29.379 20.6592 29.1387 20.8953 29.1387 21.1904C29.1387 21.4856 29.379 21.7217 29.6794 21.7217C29.9797 21.7217 30.2201 21.4856 30.2201 21.1904H35.2965C35.2965 21.4856 35.5369 21.7217 35.8372 21.7217C36.1376 21.7217 36.3779 21.4856 36.3779 21.1904C36.3779 20.8953 36.1376 20.6592 35.8372 20.6592H29.6794Z" fill="#A6A6A6"/>
<path d="M47.3762 21.6045H43.4111V39.0254H47.3762V21.6045Z" fill="#A6A6A6"/>
<path d="M49.3598 39.4316H41.4297V40.5827H49.3598V39.4316Z" fill="#A6A6A6"/>
<path d="M42.3141 20.6592C42.0137 20.6592 41.7734 20.8953 41.7734 21.1904C41.7734 21.4856 42.0137 21.7217 42.3141 21.7217C42.6145 21.7217 42.8548 21.4856 42.8548 21.1904H47.9313C47.9313 21.4856 48.1716 21.7217 48.472 21.7217C48.7724 21.7217 49.0127 21.4856 49.0127 21.1904C49.0127 20.8953 48.7724 20.6592 48.472 20.6592H42.3141Z" fill="#A6A6A6"/>
<path d="M26.8478 9.42308C26.8478 9.18696 26.6151 8.99512 26.3297 8.99512C26.0443 8.99512 25.8115 9.18696 25.8115 9.42308V9.76249C25.8115 10.0429 26.0443 10.2716 26.3297 10.2716C26.6151 10.2716 26.8478 10.0429 26.8478 9.76249V9.42308Z" fill="#A6A6A6"/>
<path d="M19.5098 11.6514C19.2695 11.6514 19.0742 11.8801 19.0742 12.1679C19.0742 12.4483 19.2695 12.6844 19.5098 12.6844H20.4109C20.6963 12.6844 20.9366 12.4556 20.9366 12.1679C20.9366 11.8875 20.7038 11.6514 20.4109 11.6514H19.5098Z" fill="#A6A6A6"/>
<path d="M32.2022 11.6514C31.9619 11.6514 31.7666 11.8801 31.7666 12.1679C31.7666 12.4483 31.9619 12.6844 32.2022 12.6844H33.1033C33.3887 12.6844 33.629 12.4556 33.629 12.1679C33.629 11.8875 33.3962 11.6514 33.1033 11.6514H32.2022Z" fill="#A6A6A6"/>
<path d="M27.6188 11.1644L26.973 10.7586C26.973 10.7586 26.8979 10.7217 26.8528 10.7217H25.8015C25.7564 10.7217 25.7189 10.7364 25.6813 10.7586L25.0355 11.1644C24.9679 11.2087 24.9304 11.2751 24.9304 11.3489V11.8211C24.9304 11.8654 24.9454 11.9096 24.9679 11.9465L25.5311 12.7656C25.5762 12.832 25.5837 12.9057 25.5537 12.9795L24.9454 14.3372C24.9454 14.3372 24.9229 14.3962 24.9229 14.4257V15.776C24.9229 15.9015 25.0205 15.9974 25.1481 15.9974H27.4911C27.6188 15.9974 27.7164 15.9015 27.7164 15.776V14.4257C27.7164 14.4257 27.7164 14.3667 27.6939 14.3372L27.0856 12.9795C27.0556 12.9131 27.0631 12.832 27.1081 12.7656L27.6714 11.9465C27.6714 11.9465 27.7089 11.8654 27.7089 11.8211V11.3489C27.7089 11.2751 27.6714 11.2013 27.6038 11.1644H27.6188Z" fill="#A6A6A6"/>
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -16,12 +16,7 @@ import {
SnapshotListPage,
CarrierListPage,
StationListPage,
// VehicleListPage,
ArticleListPage,
// CountryPreviewPage,
// VehiclePreviewPage,
// CarrierPreviewPage,
SnapshotCreatePage,
CountryCreatePage,
CityCreatePage,
@@ -31,7 +26,6 @@ import {
CityEditPage,
UserCreatePage,
UserEditPage,
// VehicleEditPage,
CarrierEditPage,
StationCreatePage,
StationPreviewPage,
@@ -75,7 +69,6 @@ const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
return <>{children}</>;
};
// Чтобы очистка сторов происходила при смене локации
const ClearStoresWrapper: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
@@ -116,65 +109,45 @@ const router = createBrowserRouter([
children: [
{ index: true, element: <MainPage /> },
// Sight
{ path: "sight", element: <SightListPage /> },
{ path: "sight/create", element: <CreateSightPage /> },
{ path: "sight/:id/edit", element: <EditSightPage /> },
// Device
{ path: "devices", element: <DevicesPage /> },
// Map
{ path: "map", element: <MapPage /> },
// Media
{ path: "media", element: <MediaListPage /> },
{ path: "media/:id", element: <MediaPreviewPage /> },
{ path: "media/:id/edit", element: <MediaEditPage /> },
// Country
{ path: "country", element: <CountryListPage /> },
{ path: "country/create", element: <CountryCreatePage /> },
{ path: "country/add", element: <CountryAddPage /> },
// { path: "country/:id", element: <CountryPreviewPage /> },
{ path: "country/:id/edit", element: <CountryEditPage /> },
// City
{ path: "city", element: <CityListPage /> },
{ path: "city/create", element: <CityCreatePage /> },
// { path: "city/:id", element: <CityPreviewPage /> },
{ path: "city/:id/edit", element: <CityEditPage /> },
// Route
{ path: "route", element: <RouteListPage /> },
{ path: "route/create", element: <RouteCreatePage /> },
{ path: "route/:id/edit", element: <RouteEditPage /> },
// User
{ path: "user", element: <UserListPage /> },
{ path: "user/create", element: <UserCreatePage /> },
{ path: "user/:id/edit", element: <UserEditPage /> },
// Snapshot
{ path: "snapshot", element: <SnapshotListPage /> },
{ path: "snapshot/create", element: <SnapshotCreatePage /> },
// Carrier
{ path: "carrier", element: <CarrierListPage /> },
{ path: "carrier/create", element: <CarrierCreatePage /> },
// { path: "carrier/:id", element: <CarrierPreviewPage /> },
{ path: "carrier/:id/edit", element: <CarrierEditPage /> },
// Station
{ path: "station", element: <StationListPage /> },
{ path: "station/create", element: <StationCreatePage /> },
{ path: "station/:id", element: <StationPreviewPage /> },
{ path: "station/:id/edit", element: <StationEditPage /> },
// Vehicle
// { path: "vehicle", element: <VehicleListPage /> },
{ path: "vehicle/create", element: <VehicleCreatePage /> },
// { path: "vehicle/:id", element: <VehiclePreviewPage /> },
// { path: "vehicle/:id/edit", element: <VehicleEditPage /> },
// Article
{ path: "article", element: <ArticleListPage /> },
{ path: "article/:id", element: <ArticlePreviewPage /> },
// { path: "media/create", element: <CreateMediaPage /> },
],
},
]);

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -1,5 +1,6 @@
@import "tailwindcss";
@plugin "@tailwindcss/typography";
button {
cursor: pointer;
}

View File

@@ -48,7 +48,6 @@ export const CarrierCreatePage = observer(() => {
languageStore.setLanguage("ru");
}, []);
// Автоматически устанавливаем выбранный город при загрузке страницы
useEffect(() => {
if (selectedCityId && !createCarrierData.city_id) {
setCreateCarrierData(

View File

@@ -74,7 +74,6 @@ export const CarrierEditPage = observer(() => {
mediaStore.getMedia();
})();
// Устанавливаем русский язык при загрузке страницы
languageStore.setLanguage("ru");
}, [id]);

View File

@@ -44,7 +44,6 @@ export const CityEditPage = observer(() => {
const { getMedia, getOneMedia } = mediaStore;
useEffect(() => {
// Устанавливаем русский язык при загрузке страницы
languageStore.setLanguage("ru");
}, []);
@@ -64,12 +63,11 @@ export const CityEditPage = observer(() => {
(async () => {
if (id) {
await getCountries("ru");
// Fetch data for all languages
const ruData = await getCity(id as string, "ru");
const enData = await getCity(id as string, "en");
const zhData = await getCity(id as string, "zh");
// Set data for each language
setEditCityData(ruData.name, ruData.country_code, ruData.arms, "ru");
setEditCityData(enData.name, enData.country_code, enData.arms, "en");
setEditCityData(zhData.name, zhData.country_code, zhData.arms, "zh");
@@ -207,7 +205,7 @@ export const CityEditPage = observer(() => {
open={isSelectMediaOpen}
onClose={() => setIsSelectMediaOpen(false)}
onSelectMedia={handleMediaSelect}
mediaType={1} // Тип медиа для иконок
mediaType={1}
/>
<UploadMediaDialog

View File

@@ -17,7 +17,6 @@ export const CountryEditPage = observer(() => {
countryStore;
useEffect(() => {
// Устанавливаем русский язык при загрузке страницы
languageStore.setLanguage("ru");
}, []);
@@ -36,12 +35,10 @@ export const CountryEditPage = observer(() => {
useEffect(() => {
(async () => {
if (id) {
// Fetch data for all languages
const ruData = await getCountry(id as string, "ru");
const enData = await getCountry(id as string, "en");
const zhData = await getCountry(id as string, "zh");
// Set data for each language
setEditCountryData(ruData.name, "ru");
setEditCountryData(enData.name, "en");
setEditCountryData(zhData.name, "zh");

View File

@@ -24,7 +24,6 @@ export const LoginPage = () => {
const { login } = authStore;
const { getUsers } = userStore;
useEffect(() => {
// Load saved credentials if they exist
const savedEmail = localStorage.getItem("rememberedEmail");
const savedPassword = localStorage.getItem("rememberedPassword");
if (savedEmail && savedPassword) {
@@ -42,7 +41,6 @@ export const LoginPage = () => {
try {
await login(email, password);
// Save or clear credentials based on remember me checkbox
if (rememberMe) {
localStorage.setItem("rememberedEmail", email);
localStorage.setItem("rememberedPassword", password);

File diff suppressed because it is too large Load Diff

View File

@@ -22,10 +22,8 @@ interface ApiSight {
longitude: number;
}
// Допуск для сравнения координат, чтобы избежать ошибок с точностью чисел.
const COORDINATE_PRECISION_TOLERANCE = 1e-9;
// Вспомогательная функция, обновленная для сравнения с допуском.
const arePathsEqual = (
path1: [number, number][],
path2: [number, number][]
@@ -136,7 +134,6 @@ class MapStore {
longitude: geometry.coordinates[0],
};
// ИЗМЕНЕНИЕ: Сравнение координат с допуском
if (
originalStation.name !== currentStation.name ||
Math.abs(originalStation.latitude - currentStation.latitude) >
@@ -155,7 +152,6 @@ class MapStore {
path: geometry.coordinates,
};
// ИЗМЕНЕНИЕ: Используется новая функция arePathsEqual с допуском
if (
originalRoute.route_number !== currentRoute.route_number ||
!arePathsEqual(originalRoute.path, currentRoute.path)
@@ -173,7 +169,6 @@ class MapStore {
longitude: geometry.coordinates[0],
};
// ИЗМЕНЕНИЕ: Сравнение координат с допуском
if (
originalSight.name !== currentSight.name ||
originalSight.description !== currentSight.description ||

View File

@@ -33,7 +33,7 @@ export const MediaEditPage = observer(() => {
const fileInputRef = useRef<HTMLInputElement>(null);
const [newFile, setNewFile] = useState<File | null>(null);
const [uploadDialogOpen, setUploadDialogOpen] = useState(false); // State for the upload dialog
const [uploadDialogOpen, setUploadDialogOpen] = useState(false);
const media = id ? mediaStore.media.find((m) => m.id === id) : null;
const [mediaName, setMediaName] = useState(media?.media_name ?? "");
@@ -55,51 +55,27 @@ export const MediaEditPage = observer(() => {
setMediaFilename(media.filename);
setMediaType(media.media_type);
// Set available media types based on current file extension
const extension = media.filename.split(".").pop()?.toLowerCase();
if (extension) {
if (["glb", "gltf"].includes(extension)) {
setAvailableMediaTypes([6]); // 3D model
setAvailableMediaTypes([6]);
} else if (
["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp"].includes(
extension
)
) {
setAvailableMediaTypes([1, 3, 4, 5]); // Photo, Icon, Watermark, Panorama
setAvailableMediaTypes([1, 3, 4, 5]);
} else if (["mp4", "webm", "mov"].includes(extension)) {
setAvailableMediaTypes([2]); // Video
setAvailableMediaTypes([2]);
}
}
}
}, [media]);
useEffect(() => {
// Устанавливаем русский язык при загрузке страницы
languageStore.setLanguage("ru");
}, []);
// const handleDrop = (e: DragEvent<HTMLDivElement>) => {
// e.preventDefault();
// e.stopPropagation();
// setIsDragging(false);
// const files = Array.from(e.dataTransfer.files);
// if (files.length > 0) {
// setNewFile(files[0]);
// setMediaFilename(files[0].name);
// setUploadDialogOpen(true); // Open dialog on file drop
// }
// };
// const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
// e.preventDefault();
// setIsDragging(true);
// };
// const handleDragLeave = () => {
// setIsDragging(false);
// };
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files && files.length > 0) {
@@ -107,26 +83,25 @@ export const MediaEditPage = observer(() => {
setNewFile(file);
setMediaFilename(file.name);
// Determine media type based on file extension
const extension = file.name.split(".").pop()?.toLowerCase();
if (extension) {
if (["glb", "gltf"].includes(extension)) {
setAvailableMediaTypes([6]); // 3D model
setAvailableMediaTypes([6]);
setMediaType(6);
} else if (
["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp"].includes(
extension
)
) {
setAvailableMediaTypes([1, 3, 4, 5]); // Photo, Icon, Watermark, Panorama
setMediaType(1); // Default to Photo
setAvailableMediaTypes([1, 3, 4, 5]);
setMediaType(1);
} else if (["mp4", "webm", "mov"].includes(extension)) {
setAvailableMediaTypes([2]); // Video
setAvailableMediaTypes([2]);
setMediaType(2);
}
}
setUploadDialogOpen(true); // Open dialog on file selection
setUploadDialogOpen(true);
}
};
@@ -143,11 +118,6 @@ export const MediaEditPage = observer(() => {
type: mediaType,
});
// If a new file was selected, the actual file upload will happen
// via the UploadMediaDialog. We just need to make sure the metadata
// is updated correctly before or after.
// Since the dialog handles the actual upload, we don't call updateMediaFile here.
setSuccess(true);
handleUploadSuccess();
} catch (err) {
@@ -158,17 +128,15 @@ export const MediaEditPage = observer(() => {
};
const handleUploadSuccess = () => {
// After successful upload in the dialog, refresh media data if needed
if (id) {
mediaStore.getOneMedia(id);
}
setNewFile(null); // Clear the new file state after successful upload
setNewFile(null);
setUploadDialogOpen(false);
setSuccess(true);
};
if (!media && id) {
// Only show loading if an ID is present and media is not yet loaded
return (
<Box className="flex justify-center items-center h-screen">
<CircularProgress />

View File

@@ -2,7 +2,6 @@ import { useState, useEffect } from "react";
import {
Stack,
Typography,
Button,
FormControl,
Accordion,
AccordionSummary,
@@ -35,6 +34,7 @@ import {
} from "@hello-pangea/dnd";
import {
AnimatedCircleButton,
authInstance,
languageStore,
routeStore,
@@ -42,7 +42,6 @@ import {
} from "@shared";
import { EditStationModal } from "../../widgets/modals/EditStationModal";
// Helper function to insert an item at a specific position (1-based index)
function insertAtPosition<T>(arr: T[], pos: number, value: T): T[] {
const index = pos - 1;
const result = [...arr];
@@ -54,7 +53,6 @@ function insertAtPosition<T>(arr: T[], pos: number, value: T): T[] {
return result;
}
// Helper function to reorder items after drag and drop
const reorder = <T,>(list: T[], startIndex: number, endIndex: number): T[] => {
const result = Array.from(list);
const [removed] = result.splice(startIndex, 1);
@@ -143,6 +141,9 @@ const LinkedItemsContentsInner = <
const [selectedItems, setSelectedItems] = useState<Set<number>>(new Set());
const [activeTab, setActiveTab] = useState(0);
const [searchQuery, setSearchQuery] = useState("");
const [isLinkingSingle, setIsLinkingSingle] = useState(false);
const [isLinkingBulk, setIsLinkingBulk] = useState(false);
const [detachingIds, setDetachingIds] = useState<Set<number>>(new Set());
useEffect(() => {}, [error]);
@@ -152,13 +153,11 @@ const LinkedItemsContentsInner = <
const availableItems = allItems
.filter((item) => !linkedItems.some((linked) => linked.id === item.id))
.filter((item) => {
// Если направление маршрута не указано, показываем все станции
if (routeDirection === undefined) return true;
// Фильтруем станции по направлению маршрута
return item.direction === routeDirection;
})
.filter((item) => {
// Фильтруем по городу из навбара
const selectedCityId = selectedCityStore.selectedCityId;
if (selectedCityId && "city_id" in item) {
return item.city_id === selectedCityId;
@@ -167,7 +166,6 @@ const LinkedItemsContentsInner = <
})
.sort((a, b) => a.name.localeCompare(b.name));
// Фильтрация по поиску для массового режима
const filteredAvailableItems = availableItems.filter((item) => {
if (!searchQuery.trim()) return true;
return String(item.name).toLowerCase().includes(searchQuery.toLowerCase());
@@ -255,6 +253,7 @@ const LinkedItemsContentsInner = <
),
};
setIsLinkingSingle(true);
authInstance
.post(`/${parentResource}/${parentId}/${childResource}`, requestData)
.then(() => {
@@ -273,12 +272,20 @@ const LinkedItemsContentsInner = <
.catch((error) => {
console.error("Error linking item:", error);
setError("Failed to link station");
})
.finally(() => {
setIsLinkingSingle(false);
});
}
};
const deleteItem = (itemId: number) => {
setError(null);
setDetachingIds((prev) => {
const next = new Set(prev);
next.add(itemId);
return next;
});
authInstance
.delete(`/${parentResource}/${parentId}/${childResource}`, {
data: { [`${childResource}_id`]: itemId },
@@ -290,6 +297,13 @@ const LinkedItemsContentsInner = <
.catch((error) => {
console.error("Error deleting item:", error);
setError("Failed to delete station");
})
.finally(() => {
setDetachingIds((prev) => {
const next = new Set(prev);
next.delete(itemId);
return next;
});
});
};
@@ -316,6 +330,7 @@ const LinkedItemsContentsInner = <
if (selectedItems.size === 0) return;
setError(null);
setIsLinkingBulk(true);
const selectedStations = Array.from(selectedItems).map((id) => ({ id }));
const requestData = {
stations: [
@@ -335,6 +350,9 @@ const LinkedItemsContentsInner = <
.catch((error) => {
console.error("Error linking items:", error);
setError("Failed to link stations");
})
.finally(() => {
setIsLinkingBulk(false);
});
};
@@ -404,7 +422,7 @@ const LinkedItemsContentsInner = <
))}
{type === "edit" && (
<TableCell>
<Button
<AnimatedCircleButton
variant="outlined"
color="error"
size="small"
@@ -412,9 +430,11 @@ const LinkedItemsContentsInner = <
e.stopPropagation();
deleteItem(item.id);
}}
disabled={detachingIds.has(item.id)}
loading={detachingIds.has(item.id)}
>
Отвязать
</Button>
</AnimatedCircleButton>
</TableCell>
)}
</TableRow>
@@ -525,14 +545,15 @@ const LinkedItemsContentsInner = <
/>
</FormControl>
<Button
<AnimatedCircleButton
variant="contained"
onClick={linkItem}
disabled={!selectedItemId}
disabled={!selectedItemId || isLinkingSingle}
loading={isLinkingSingle}
sx={{ alignSelf: "flex-start" }}
>
Добавить
</Button>
</AnimatedCircleButton>
</Stack>
)}
@@ -562,7 +583,14 @@ const LinkedItemsContentsInner = <
size="small"
/>
}
label={String(item.name)}
label={
<div className="flex justify-between items-center w-full gap-10">
<p>{String(item.name)}</p>
<p className="text-xs text-gray-500 max-w-[300px] truncate text-ellipsis">
{String(item.description)}
</p>
</div>
}
sx={{
margin: 0,
"& .MuiFormControlLabel-label": {
@@ -585,14 +613,15 @@ const LinkedItemsContentsInner = <
</Stack>
</Paper>
<Button
<AnimatedCircleButton
variant="contained"
onClick={handleBulkLink}
disabled={selectedItems.size === 0}
disabled={selectedItems.size === 0 || isLinkingBulk}
loading={isLinkingBulk}
sx={{ alignSelf: "flex-start" }}
>
Добавить выбранные ({selectedItems.size})
</Button>
</AnimatedCircleButton>
</Stack>
)}
</Box>

View File

@@ -13,7 +13,7 @@ import {
DialogContent,
DialogActions,
} from "@mui/material";
import { MediaViewer } from "@widgets";
import { MediaViewer, VideoPreviewCard } from "@widgets";
import { observer } from "mobx-react-lite";
import { ArrowLeft, Loader2, Save, Plus } from "lucide-react";
import { useEffect, useState, useMemo } from "react";
@@ -24,9 +24,10 @@ import { articlesStore } from "../../../shared/store/ArticlesStore";
import { Route, routeStore } from "../../../shared/store/RouteStore";
import {
languageStore,
SelectArticleModal,
ArticleSelectOrCreateDialog,
SelectMediaDialog,
selectedCityStore,
UploadMediaDialog,
} from "@shared";
export const RouteCreatePage = observer(() => {
@@ -37,8 +38,9 @@ export const RouteCreatePage = observer(() => {
const [govRouteNumber, setGovRouteNumber] = useState("");
const [governorAppeal, setGovernorAppeal] = useState<string>("");
const [direction, setDirection] = useState("backward");
const [scaleMin, setScaleMin] = useState("");
const [scaleMax, setScaleMax] = useState("");
const [scaleMin, setScaleMin] = useState("10");
const [scaleMax, setScaleMax] = useState("100");
const [routeName, setRouteName] = useState("");
const [turn, setTurn] = useState("");
const [centerLat, setCenterLat] = useState("");
const [centerLng, setCenterLng] = useState("");
@@ -48,6 +50,8 @@ export const RouteCreatePage = observer(() => {
useState(false);
const [isSelectVideoDialogOpen, setIsSelectVideoDialogOpen] = useState(false);
const [isVideoPreviewOpen, setIsVideoPreviewOpen] = useState(false);
const [isUploadVideoDialogOpen, setIsUploadVideoDialogOpen] = useState(false);
const [fileToUpload, setFileToUpload] = useState<File | null>(null);
const { language } = languageStore;
useEffect(() => {
@@ -55,7 +59,6 @@ export const RouteCreatePage = observer(() => {
articlesStore.getArticleList();
}, [language]);
// Фильтруем перевозчиков только из выбранного города
const filteredCarriers = useMemo(() => {
const carriers =
carrierStore.carriers[language as keyof typeof carrierStore.carriers]
@@ -110,6 +113,7 @@ export const RouteCreatePage = observer(() => {
const handleArticleSelect = (articleId: number) => {
setGovernorAppeal(articleId.toString());
setIsSelectArticleDialogOpen(false);
articlesStore.getArticleList();
};
const handleVideoSelect = (media: {
@@ -122,6 +126,26 @@ export const RouteCreatePage = observer(() => {
setIsSelectVideoDialogOpen(false);
};
const handleVideoFileSelect = (file?: File) => {
if (file) {
setFileToUpload(file);
setIsUploadVideoDialogOpen(true);
} else {
setIsSelectVideoDialogOpen(true);
}
};
const handleVideoUpload = (media: {
id: string;
filename: string;
media_name?: string;
media_type: number;
}) => {
setVideoPreview(media.id);
setIsUploadVideoDialogOpen(false);
setFileToUpload(null);
};
const handleVideoPreviewClick = () => {
setIsVideoPreviewOpen(true);
};
@@ -129,23 +153,72 @@ export const RouteCreatePage = observer(() => {
const handleCreateRoute = async () => {
try {
setIsLoading(true);
// Преобразуем значения в нужные типы
if (!routeName.trim()) {
toast.error("Заполните название маршрута");
setIsLoading(false);
return;
}
if (!carrier) {
toast.error("Выберите перевозчика");
setIsLoading(false);
return;
}
if (!routeNumber.trim()) {
toast.error("Заполните номер маршрута");
setIsLoading(false);
return;
}
if (!govRouteNumber.trim()) {
toast.error("Заполните номер маршрута в Говорящем Городе");
setIsLoading(false);
return;
}
if (!governorAppeal) {
toast.error("Выберите статью для обращения к пассажирам");
setIsLoading(false);
return;
}
const validationResult = validateCoordinates(routeCoords);
if (validationResult !== true) {
toast.error(validationResult);
setIsLoading(false);
return;
}
const scale_min = scaleMin ? Number(scaleMin) : null;
const scale_max = scaleMax ? Number(scaleMax) : null;
if (
scale_min === 0 ||
scale_max === 0 ||
scale_min === null ||
scale_max === null
) {
toast.error("Масштабы не могут быть равны 0");
setIsLoading(false);
return;
}
if (
scale_min !== null &&
scale_max !== null &&
scale_max !== undefined &&
scale_min > scale_max
) {
toast.error("Максимальный масштаб не может быть меньше минимального");
setIsLoading(false);
return;
}
const carrier_id = Number(carrier);
const governor_appeal = Number(governorAppeal);
const scale_min = scaleMin ? Number(scaleMin) : undefined;
const scale_max = scaleMax ? Number(scaleMax) : undefined;
const rotate = turn ? Number(turn) : undefined;
const center_latitude = centerLat ? Number(centerLat) : undefined;
const center_longitude = centerLng ? Number(centerLng) : undefined;
const route_direction = direction === "forward";
const validationResult = validateCoordinates(routeCoords);
if (validationResult !== true) {
toast.error(validationResult);
return;
}
// Координаты маршрута как массив массивов чисел
const path = routeCoords
.trim()
.split("\n")
@@ -157,7 +230,6 @@ export const RouteCreatePage = observer(() => {
return [lat, lon];
});
// Собираем объект маршрута
const newRoute: Partial<Route> = {
carrier:
carrierStore.carriers[
@@ -167,9 +239,10 @@ export const RouteCreatePage = observer(() => {
route_number: routeNumber,
route_sys_number: govRouteNumber,
governor_appeal,
route_name: routeName,
route_direction,
scale_min,
scale_max,
scale_min: scale_min !== null ? scale_min : 0,
scale_max: scale_max !== null ? scale_max : 0,
rotate,
center_latitude,
center_longitude,
@@ -189,7 +262,6 @@ export const RouteCreatePage = observer(() => {
}
};
// Получаем название выбранной статьи для отображения
const selectedArticle = articlesStore.articleList.ru.data.find(
(article) => article.id === Number(governorAppeal)
);
@@ -208,6 +280,13 @@ export const RouteCreatePage = observer(() => {
<div className="flex flex-col gap-10 w-full items-end">
<Box className="flex flex-col gap-6 w-full">
<TextField
className="w-full"
label="Название маршрута"
required
value={routeName}
onChange={(e) => setRouteName(e.target.value)}
/>
<FormControl fullWidth required>
<InputLabel>Выберите перевозчика</InputLabel>
<Select
@@ -247,7 +326,6 @@ export const RouteCreatePage = observer(() => {
const lines = routeCoords.split("\n");
const lastLine = lines[lines.length - 1];
// Если мы на последней строке и она не пустая
if (lastLine && lastLine.trim()) {
e.preventDefault();
const newValue = routeCoords + "\n";
@@ -279,6 +357,7 @@ export const RouteCreatePage = observer(() => {
},
}}
/>
<TextField
className="w-full"
label="Номер маршрута в Говорящем Городе"
@@ -287,99 +366,42 @@ export const RouteCreatePage = observer(() => {
onChange={(e) => setGovRouteNumber(e.target.value)}
/>
{/* Заменяем Select на кнопку для выбора статьи */}
<Box className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">
Обращение к пассажирам
</label>
<Box className="flex gap-2">
<TextField
className="flex-1"
value={selectedArticle?.heading || "Статья не выбрана"}
placeholder="Выберите статью"
disabled
sx={{
"& .MuiInputBase-input": {
color: selectedArticle ? "inherit" : "#999",
},
}}
/>
<Button
variant="outlined"
onClick={() => setIsSelectArticleDialogOpen(true)}
startIcon={<Plus size={16} />}
sx={{ minWidth: "auto", px: 2 }}
>
Выбрать
</Button>
</Box>
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
Обращение к пассажирам
</Typography>
<Box className="flex gap-2">
<TextField
className="flex-1"
value={selectedArticle?.heading || "Статья не выбрана"}
placeholder="Выберите статью"
disabled
fullWidth
sx={{
"& .MuiInputBase-input": {
color: selectedArticle ? "inherit" : "#999",
},
}}
/>
<Button
variant="outlined"
onClick={() => setIsSelectArticleDialogOpen(true)}
startIcon={<Plus size={16} />}
sx={{ minWidth: "auto", px: 2 }}
>
Выбрать
</Button>
</Box>
{/* Селектор видеозаставки */}
<Box className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">
Видеозаставка
</label>
<Box className="flex gap-2">
<Box
className="flex-1"
onClick={handleVideoPreviewClick}
sx={{
cursor:
videoPreview && videoPreview !== "" ? "pointer" : "default",
}}
>
<Box
className="w-full h-[50px] border border-gray-400 rounded-sm flex items-center justify-between px-4"
sx={{
"& .MuiInputBase-input": {
color:
videoPreview && videoPreview !== ""
? "inherit"
: "#999",
cursor:
videoPreview && videoPreview !== ""
? "pointer"
: "default",
},
}}
>
<Typography variant="body1" className="text-sm">
{videoPreview && videoPreview !== ""
? "Видео выбрано"
: "Видео не выбрано"}
</Typography>
{videoPreview && videoPreview !== "" && (
<Box
onClick={(e) => {
e.stopPropagation();
setVideoPreview("");
}}
sx={{
cursor: "pointer",
color: "#999",
"&:hover": {
color: "#666",
},
}}
>
<Typography variant="body1" className="text-lg font-bold">
×
</Typography>
</Box>
)}
</Box>
</Box>
<Button
variant="outlined"
onClick={() => setIsSelectVideoDialogOpen(true)}
startIcon={<Plus size={16} />}
sx={{ minWidth: "auto", px: 2 }}
>
Выбрать
</Button>
</Box>
</Box>
<VideoPreviewCard
title="Видеозаставка"
videoId={videoPreview}
onVideoClick={handleVideoPreviewClick}
onDeleteVideoClick={() => {
setVideoPreview("");
}}
onSelectVideoClick={handleVideoFileSelect}
className="w-full"
/>
<FormControl fullWidth required>
<InputLabel>Прямой/обратный маршрут</InputLabel>
@@ -395,15 +417,53 @@ export const RouteCreatePage = observer(() => {
<TextField
className="w-full"
label="Масштаб (мин)"
type="number"
value={scaleMin}
onChange={(e) => setScaleMin(e.target.value)}
onChange={(e) => {
let value = e.target.value;
if (Number(value) > 297) {
value = "297";
}
if (Number(value) < 10) {
value = "10";
}
setScaleMin(value);
if (value && scaleMax && Number(value) > Number(scaleMax)) {
setScaleMax(value);
}
}}
error={
scaleMin !== "" &&
scaleMax !== "" &&
Number(scaleMin) > Number(scaleMax)
}
required
helperText={
scaleMin !== "" &&
scaleMax !== "" &&
Number(scaleMin) > Number(scaleMax)
? "Минимальный масштаб не может быть больше максимального"
: ""
}
/>
<TextField
className="w-full"
label="Масштаб (макс)"
type="number"
value={scaleMax}
onChange={(e) => setScaleMax(e.target.value)}
required
onChange={(e) => {
if (Number(e.target.value) > 300) {
e.target.value = "300";
}
const value = e.target.value;
setScaleMax(value);
}}
/>
<TextField
className="w-full"
label="Поворот"
@@ -440,23 +500,17 @@ export const RouteCreatePage = observer(() => {
</Button>
</div>
</div>
{/* Модальное окно выбора статьи */}
<SelectArticleModal
<ArticleSelectOrCreateDialog
open={isSelectArticleDialogOpen}
onClose={() => setIsSelectArticleDialogOpen(false)}
onSelectArticle={handleArticleSelect}
/>
{/* Модальное окно выбора видео */}
<SelectMediaDialog
open={isSelectVideoDialogOpen}
onClose={() => setIsSelectVideoDialogOpen(false)}
onSelectMedia={handleVideoSelect}
mediaType={2}
/>
{/* Модальное окно предпросмотра видео */}
{videoPreview && videoPreview !== "" && (
<Dialog
open={isVideoPreviewOpen}
@@ -483,6 +537,18 @@ export const RouteCreatePage = observer(() => {
</DialogActions>
</Dialog>
)}
<UploadMediaDialog
open={isUploadVideoDialogOpen}
onClose={() => {
setIsUploadVideoDialogOpen(false);
setFileToUpload(null);
}}
hardcodeType="video_preview"
contextObjectName={routeName || "Маршрут"}
contextType="sight"
initialFile={fileToUpload || undefined}
afterUpload={handleVideoUpload}
/>
</Paper>
);
});

View File

@@ -13,7 +13,7 @@ import {
DialogContent,
DialogActions,
} from "@mui/material";
import { MediaViewer } from "@widgets";
import { MediaViewer, VideoPreviewCard } from "@widgets";
import { observer } from "mobx-react-lite";
import { ArrowLeft, Copy, Save, Plus } from "lucide-react";
import { useEffect, useState } from "react";
@@ -24,8 +24,9 @@ import { articlesStore } from "../../../shared/store/ArticlesStore";
import {
routeStore,
languageStore,
SelectArticleModal,
ArticleSelectOrCreateDialog,
SelectMediaDialog,
UploadMediaDialog,
} from "@shared";
import { toast } from "react-toastify";
import { stationsStore } from "@shared";
@@ -40,12 +41,13 @@ export const RouteEditPage = observer(() => {
useState(false);
const [isSelectVideoDialogOpen, setIsSelectVideoDialogOpen] = useState(false);
const [isVideoPreviewOpen, setIsVideoPreviewOpen] = useState(false);
const [isUploadVideoDialogOpen, setIsUploadVideoDialogOpen] = useState(false);
const [fileToUpload, setFileToUpload] = useState<File | null>(null);
const { language } = languageStore;
const [coordinates, setCoordinates] = useState<string>("");
useEffect(() => {
const fetchData = async () => {
// Устанавливаем русский язык при загрузке страницы
const response = await routeStore.getRoute(Number(id));
routeStore.setEditRouteData(response);
languageStore.setLanguage("ru");
@@ -72,10 +74,67 @@ export const RouteEditPage = observer(() => {
}, [editRouteData.path]);
const handleSave = async () => {
// Валидация обязательных полей
if (!editRouteData.route_name?.trim()) {
toast.error("Заполните название маршрута");
return;
}
if (!editRouteData.carrier_id) {
toast.error("Выберите перевозчика");
return;
}
if (!editRouteData.route_number?.trim()) {
toast.error("Заполните номер маршрута");
return;
}
if (!editRouteData.route_sys_number?.trim()) {
toast.error("Заполните номер маршрута в Говорящем Городе");
return;
}
if (!editRouteData.governor_appeal) {
toast.error("Выберите статью для обращения к пассажирам");
return;
}
const validationResult = validateCoordinates(coordinates);
if (validationResult !== true) {
toast.error(validationResult);
return;
}
// Валидация масштабов
if (
editRouteData.scale_min !== null &&
editRouteData.scale_min !== undefined &&
editRouteData.scale_max !== null &&
editRouteData.scale_max !== undefined &&
editRouteData.scale_min > editRouteData.scale_max
) {
toast.error("Максимальный масштаб не может быть меньше минимального");
return;
}
if (
editRouteData.scale_min === 0 ||
editRouteData.scale_max === 0 ||
editRouteData.scale_min === null ||
editRouteData.scale_max === null
) {
toast.error("Масштабы не могут быть равны 0");
setIsLoading(false);
return;
}
setIsLoading(true);
await routeStore.editRoute(Number(id));
toast.success("Маршрут успешно сохранен");
setIsLoading(false);
try {
await routeStore.editRoute(Number(id));
toast.success("Маршрут успешно сохранен");
} catch (error) {
console.error(error);
toast.error("Произошла ошибка при сохранении маршрута");
} finally {
setIsLoading(false);
}
};
const validateCoordinates = (value: string) => {
@@ -125,6 +184,8 @@ export const RouteEditPage = observer(() => {
governor_appeal: articleId,
});
setIsSelectArticleDialogOpen(false);
// Обновляем список статей после создания новой
articlesStore.getArticleList();
};
const handleVideoSelect = (media: {
@@ -139,6 +200,28 @@ export const RouteEditPage = observer(() => {
setIsSelectVideoDialogOpen(false);
};
const handleVideoFileSelect = (file?: File) => {
if (file) {
setFileToUpload(file);
setIsUploadVideoDialogOpen(true);
} else {
setIsSelectVideoDialogOpen(true);
}
};
const handleVideoUpload = (media: {
id: string;
filename: string;
media_name?: string;
media_type: number;
}) => {
routeStore.setEditRouteData({
video_preview: media.id,
});
setIsUploadVideoDialogOpen(false);
setFileToUpload(null);
};
const handleVideoPreviewClick = () => {
if (editRouteData.video_preview && editRouteData.video_preview !== "") {
setIsVideoPreviewOpen(true);
@@ -164,6 +247,17 @@ export const RouteEditPage = observer(() => {
<div className="flex flex-col gap-10 w-full items-end">
<Box className="flex flex-col gap-6 w-full">
<TextField
className="w-full"
label="Название маршрута"
required
value={editRouteData.route_name || ""}
onChange={(e) =>
routeStore.setEditRouteData({
route_name: e.target.value,
})
}
/>
<FormControl fullWidth required>
<InputLabel>Выберите перевозчика</InputLabel>
<Select
@@ -235,7 +329,6 @@ export const RouteEditPage = observer(() => {
const lines = coordinates.split("\n");
const lastLine = lines[lines.length - 1];
// Если мы на последней строке и она не пустая
if (lastLine && lastLine.trim()) {
e.preventDefault();
const newValue = coordinates + "\n";
@@ -279,110 +372,6 @@ export const RouteEditPage = observer(() => {
}
/>
{/* Заменяем Select на кнопку для выбора статьи */}
<Box className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">
Обращение к пассажирам
</label>
<Box className="flex gap-2">
<TextField
className="flex-1"
value={selectedArticle?.heading || "Статья не выбрана"}
placeholder="Выберите статью"
disabled
sx={{
"& .MuiInputBase-input": {
color: selectedArticle ? "inherit" : "#999",
},
}}
/>
<Button
variant="outlined"
onClick={() => setIsSelectArticleDialogOpen(true)}
startIcon={<Plus size={16} />}
sx={{ minWidth: "auto", px: 2 }}
>
Выбрать
</Button>
</Box>
</Box>
{/* Селектор видеозаставки */}
<Box className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">
Видеозаставка
</label>
<Box className="flex gap-2">
<Box
className="flex-1"
onClick={handleVideoPreviewClick}
sx={{
cursor:
editRouteData.video_preview &&
editRouteData.video_preview !== ""
? "pointer"
: "default",
}}
>
<Box
className="w-full h-[50px] border border-gray-400 rounded-sm flex items-center justify-between px-4"
sx={{
"& .MuiInputBase-input": {
color:
editRouteData.video_preview &&
editRouteData.video_preview !== ""
? "inherit"
: "#999",
cursor:
editRouteData.video_preview &&
editRouteData.video_preview !== ""
? "pointer"
: "default",
},
}}
>
<Typography variant="body1" className="text-sm">
{editRouteData.video_preview &&
editRouteData.video_preview !== ""
? "Видео выбрано"
: "Видео не выбрано"}
</Typography>
{editRouteData.video_preview &&
editRouteData.video_preview !== "" && (
<Box
onClick={(e) => {
e.stopPropagation();
routeStore.setEditRouteData({ video_preview: "" });
}}
sx={{
cursor: "pointer",
color: "#999",
"&:hover": {
color: "#666",
},
}}
>
<Typography
variant="body1"
className="text-lg font-bold"
>
×
</Typography>
</Box>
)}
</Box>
</Box>
<Button
variant="outlined"
onClick={() => setIsSelectVideoDialogOpen(true)}
startIcon={<Plus size={16} />}
sx={{ minWidth: "auto", px: 2 }}
>
Выбрать
</Button>
</Box>
</Box>
<FormControl fullWidth required>
<InputLabel>Прямой/обратный маршрут</InputLabel>
<Select
@@ -401,23 +390,69 @@ export const RouteEditPage = observer(() => {
<TextField
className="w-full"
label="Масштаб (мин)"
type="number"
value={editRouteData.scale_min ?? ""}
onChange={(e) =>
onChange={(e) => {
let value = e.target.value === "" ? null : e.target.value;
if (value && Number(value) > 297) {
value = "297";
}
if (value && Number(value) < 10) {
value = "10";
}
routeStore.setEditRouteData({
scale_min:
e.target.value === "" ? null : parseFloat(e.target.value),
})
}
scale_min: value ? Number(value) : null,
});
// Если максимальный масштаб стал меньше минимального, обновляем его
if (
value !== null &&
editRouteData.scale_max !== null &&
editRouteData.scale_max !== undefined &&
value &&
Number(value) > (editRouteData.scale_max ?? 0)
) {
routeStore.setEditRouteData({
scale_max: value ? Number(value) : null,
});
}
}}
required
/>
<TextField
className="w-full"
required
label="Масштаб (макс)"
type="number"
value={editRouteData.scale_max ?? ""}
onChange={(e) =>
onChange={(e) => {
let value = e.target.value;
if (Number(value) > 300) {
value = "300";
}
routeStore.setEditRouteData({
scale_max:
e.target.value === "" ? null : parseFloat(e.target.value),
})
scale_max: value === "" ? null : parseFloat(value),
});
}}
error={
editRouteData.scale_min !== null &&
editRouteData.scale_min !== undefined &&
editRouteData.scale_max !== null &&
editRouteData.scale_max !== undefined &&
editRouteData.scale_max < editRouteData.scale_min
}
helperText={
editRouteData.scale_min !== null &&
editRouteData.scale_min !== undefined &&
editRouteData.scale_max !== null &&
editRouteData.scale_max !== undefined &&
editRouteData.scale_max < editRouteData.scale_min
? "Максимальный масштаб не может быть меньше минимального"
: ""
}
/>
<TextField
@@ -453,6 +488,43 @@ export const RouteEditPage = observer(() => {
})
}
/>
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
Обращение к пассажирам
</Typography>
<Box className="flex gap-2">
<TextField
className="flex-1"
value={selectedArticle?.heading || "Статья не выбрана"}
placeholder="Выберите статью"
disabled
fullWidth
sx={{
"& .MuiInputBase-input": {
color: selectedArticle ? "inherit" : "#999",
},
}}
/>
<Button
variant="outlined"
onClick={() => setIsSelectArticleDialogOpen(true)}
startIcon={<Plus size={16} />}
sx={{ minWidth: "auto", px: 2 }}
>
Выбрать
</Button>
</Box>
<VideoPreviewCard
title="Видеозаставка"
videoId={editRouteData.video_preview}
onVideoClick={handleVideoPreviewClick}
onDeleteVideoClick={() => {
routeStore.setEditRouteData({ video_preview: "" });
}}
onSelectVideoClick={handleVideoFileSelect}
className="w-full"
/>
</Box>
<LinkedItems
@@ -493,23 +565,17 @@ export const RouteEditPage = observer(() => {
</Button>
</div>
</div>
{/* Модальное окно выбора статьи */}
<SelectArticleModal
<ArticleSelectOrCreateDialog
open={isSelectArticleDialogOpen}
onClose={() => setIsSelectArticleDialogOpen(false)}
onSelectArticle={handleArticleSelect}
/>
{/* Модальное окно выбора видео */}
<SelectMediaDialog
open={isSelectVideoDialogOpen}
onClose={() => setIsSelectVideoDialogOpen(false)}
onSelectMedia={handleVideoSelect}
mediaType={2}
/>
{/* Модальное окно предпросмотра видео */}
<Dialog
open={isVideoPreviewOpen}
onClose={() => setIsVideoPreviewOpen(false)}
@@ -519,19 +585,33 @@ export const RouteEditPage = observer(() => {
<DialogTitle>Предпросмотр видео</DialogTitle>
<DialogContent>
<Box className="flex justify-center items-center p-4">
<MediaViewer
media={{
id: editRouteData.video_preview,
media_type: 2,
filename: "video_preview",
}}
/>
{editRouteData.video_preview && (
<MediaViewer
media={{
id: editRouteData.video_preview,
media_type: 2,
filename: "video_preview",
}}
/>
)}
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => setIsVideoPreviewOpen(false)}>Закрыть</Button>
</DialogActions>
</Dialog>
<UploadMediaDialog
open={isUploadVideoDialogOpen}
onClose={() => {
setIsUploadVideoDialogOpen(false);
setFileToUpload(null);
}}
hardcodeType="video_preview"
contextObjectName={editRouteData.route_name || "Маршрут"}
contextType="sight"
initialFile={fileToUpload || undefined}
afterUpload={handleVideoUpload}
/>
</Paper>
);
});

View File

@@ -50,6 +50,22 @@ export const RouteListPage = observer(() => {
);
},
},
{
field: "route_name",
headerName: "Название маршрута",
flex: 1,
renderCell: (params: GridRenderCellParams) => {
return (
<div className="w-full h-full flex items-center">
{params.value ? (
params.value
) : (
<Minus size={20} className="text-red-500" />
)}
</div>
);
},
},
{
field: "route_number",
headerName: "Номер маршрута",
@@ -100,9 +116,7 @@ export const RouteListPage = observer(() => {
<button onClick={() => navigate(`/route-preview/${params.row.id}`)}>
<Map size={20} className="text-purple-500" />
</button>
{/* <button onClick={() => navigate(`/route/${params.row.id}`)}>
<Eye size={20} className="text-green-500" />
</button> */}
<button
onClick={() => {
setIsDeleteModalOpen(true);
@@ -122,6 +136,7 @@ export const RouteListPage = observer(() => {
carrier_id: route.carrier_id,
route_number: route.route_number,
route_direction: route.route_direction ? "Прямой" : "Обратный",
route_name: route.route_name,
}));
return (

View File

@@ -47,10 +47,8 @@ export function InfiniteCanvas({
const [startPosition, setStartPosition] = useState({ x: 0, y: 0 });
const [isPointerDown, setIsPointerDown] = useState(false);
// Флаг для предотвращения конфликта между пользовательским вводом и данными маршрута
const [isUserInteracting, setIsUserInteracting] = useState(false);
// Реф для отслеживания последнего значения originalRouteData?.rotate
const lastOriginalRotation = useRef<number | undefined>(undefined);
useEffect(() => {
@@ -68,7 +66,7 @@ export function InfiniteCanvas({
const handlePointerDown = (e: FederatedMouseEvent) => {
setIsPointerDown(true);
setIsDragging(false);
setIsUserInteracting(true); // Устанавливаем флаг взаимодействия пользователя
setIsUserInteracting(true);
setStartPosition({
x: position.x,
y: position.y,
@@ -81,13 +79,9 @@ export function InfiniteCanvas({
e.stopPropagation();
};
// Устанавливаем rotation только при изменении originalRouteData и отсутствии взаимодействия пользователя
useEffect(() => {
const newRotation = originalRouteData?.rotate ?? 0;
// Обновляем rotation только если:
// 1. Пользователь не взаимодействует с канвасом
// 2. Значение действительно изменилось
if (!isUserInteracting && lastOriginalRotation.current !== newRotation) {
setRotation((newRotation * Math.PI) / 180);
lastOriginalRotation.current = newRotation;
@@ -97,7 +91,6 @@ export function InfiniteCanvas({
const handlePointerMove = (e: FederatedMouseEvent) => {
if (!isPointerDown) return;
// Проверяем, началось ли перетаскивание
if (!isDragging) {
const dx = e.globalX - startMousePosition.x;
const dy = e.globalY - startMousePosition.y;
@@ -119,10 +112,8 @@ export function InfiniteCanvas({
e.globalX - center.x
);
// Calculate rotation difference in radians
const rotationDiff = currentAngle - startAngle;
// Update rotation
setRotation(startRotation + rotationDiff);
const cosDelta = Math.cos(rotationDiff);
@@ -149,15 +140,13 @@ export function InfiniteCanvas({
};
const handlePointerUp = (e: FederatedMouseEvent) => {
// Если не было перетаскивания, то это простой клик - закрываем виджет
if (!isDragging) {
setSelectedSight(undefined);
}
setIsPointerDown(false);
setIsDragging(false);
// Сбрасываем флаг взаимодействия через небольшую задержку
// чтобы избежать немедленного срабатывания useEffect
setTimeout(() => {
setIsUserInteracting(false);
}, 100);
@@ -166,29 +155,25 @@ export function InfiniteCanvas({
const handleWheel = (e: FederatedWheelEvent) => {
e.stopPropagation();
setIsUserInteracting(true); // Устанавливаем флаг при зуме
setIsUserInteracting(true);
// Get mouse position relative to canvas
const mouseX = e.globalX - position.x;
const mouseY = e.globalY - position.y;
// Calculate new scale
const scaleMin = (routeData?.scale_min ?? 10) / SCALE_FACTOR;
const scaleMax = (routeData?.scale_max ?? 20) / SCALE_FACTOR;
const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1; // Zoom out/in
const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1;
const newScale = Math.max(scaleMin, Math.min(scaleMax, scale * zoomFactor));
const actualZoomFactor = newScale / scale;
if (scale === newScale) {
// Сбрасываем флаг, если зум не изменился
setTimeout(() => {
setIsUserInteracting(false);
}, 100);
return;
}
// Update position to zoom towards mouse cursor
setPosition({
x: position.x + mouseX * (1 - actualZoomFactor),
y: position.y + mouseY * (1 - actualZoomFactor),
@@ -196,7 +181,6 @@ export function InfiniteCanvas({
setScale(newScale);
// Сбрасываем флаг взаимодействия через задержку
setTimeout(() => {
setIsUserInteracting(false);
}, 100);

View File

@@ -1,12 +1,19 @@
import { Stack, Typography, Button } from "@mui/material";
import { Box, Stack, Typography, Button } from "@mui/material";
import { useNavigate, useNavigationType } from "react-router";
import { MediaViewer } from "@widgets";
import { useMapData } from "./MapDataContext";
import { observer } from "mobx-react-lite";
import { useEffect, useState } from "react";
import { authInstance } from "@shared";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import LanguageSelector from "./web-gl/LanguageSelector";
export const LeftSidebar = observer(() => {
type LeftSidebarProps = {
open: boolean;
onToggle: () => void;
};
export const LeftSidebar = observer(({ open, onToggle }: LeftSidebarProps) => {
const navigate = useNavigate();
const navigationType = useNavigationType(); // PUSH, POP, REPLACE
const { routeData } = useMapData();
@@ -35,101 +42,146 @@ export const LeftSidebar = observer(() => {
};
return (
<Stack direction="column" width="300px" p={2} bgcolor="primary.main">
<button
onClick={handleBack}
type="button"
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
gap: 10,
color: "#fff",
backgroundColor: "#222",
borderRadius: 10,
height: 40,
width: "100%",
border: "none",
cursor: "pointer",
}}
>
<p>Назад</p>
</button>
<Box
sx={{
position: "relative",
height: "100%",
color: "#fff",
transition: "padding 0.3s ease",
p: open ? 2 : 0,
display: "flex",
flexDirection: "column",
alignItems: "stretch",
justifyContent: "flex-start",
}}
>
<Stack
direction="column"
alignItems="center"
justifyContent="center"
my={10}
height="100%"
width="100%"
spacing={4}
alignItems="stretch"
sx={{
opacity: open ? 1 : 0,
transition: "opacity 0.25s ease",
pointerEvents: open ? "auto" : "none",
display: open ? "flex" : "none",
}}
>
<div
style={{
maxWidth: 200,
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 10,
<Button
onClick={handleBack}
variant="contained"
color="primary"
sx={{
backgroundColor: "#222",
color: "#fff",
borderRadius: 1.5,
px: 2,
py: 1,
"&:hover": {
backgroundColor: "#2d2d2d",
},
}}
fullWidth
startIcon={<ArrowBackIcon />}
>
{carrierThumbnail && (
Назад
</Button>
<Stack
direction="column"
alignItems="center"
justifyContent="center"
spacing={3}
>
<div
style={{
maxWidth: 200,
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 10,
}}
>
{carrierThumbnail && (
<MediaViewer
media={{
id: carrierThumbnail,
media_type: 1, // Тип "Фото" для логотипа
filename: "route_thumbnail",
}}
fullWidth
fullHeight
/>
)}
<Typography sx={{ color: "#fff" }} textAlign="center">
При поддержке Правительства
</Typography>
</div>
</Stack>
<Stack
direction="column"
alignItems="center"
justifyContent="center"
spacing={2}
>
<Button variant="outlined" color="warning" fullWidth>
Достопримечательности
</Button>
<Button variant="outlined" color="warning" fullWidth>
Остановки
</Button>
</Stack>
<Stack
direction="column"
alignItems="center"
maxHeight={150}
justifyContent="center"
flexGrow={1}
>
{carrierLogo && (
<MediaViewer
media={{
id: carrierThumbnail,
id: carrierLogo,
media_type: 1, // Тип "Фото" для логотипа
filename: "route_thumbnail",
filename: "route_thumbnail_logo",
}}
fullWidth
fullHeight
/>
)}
<Typography sx={{ mb: 2, color: "#fff" }} textAlign="center">
При поддержке Правительства
</Typography>{" "}
</div>
</Stack>
<Typography variant="h6" textAlign="center" sx={{ color: "#fff" }}>
#ВсемПоПути
</Typography>
</Stack>
<Stack
direction="column"
alignItems="center"
justifyContent="center"
my={10}
spacing={2}
>
<Button variant="outlined" color="warning" fullWidth>
Достопримечательности
</Button>
<Button variant="outlined" color="warning" fullWidth>
Остановки
</Button>
</Stack>
{!open && (
<Typography
variant="caption"
sx={{
color: "rgba(255,255,255,0.6)",
writingMode: "vertical-rl",
transform: "rotate(180deg)",
letterSpacing: 4,
position: "absolute",
top: "50%",
left: "50%",
transformOrigin: "center",
translate: "-50% -50%",
opacity: 0.6,
pointerEvents: "none",
}}
>
#ВсемПоПути
</Typography>
)}
<Stack
direction="column"
alignItems="center"
maxHeight={150}
justifyContent="center"
my={10}
>
{carrierLogo && (
<MediaViewer
media={{
id: carrierLogo,
media_type: 1, // Тип "Фото" для логотипа
filename: "route_thumbnail_logo",
}}
fullHeight
/>
)}
</Stack>
<Typography
variant="h6"
textAlign="center"
mt="auto"
sx={{ color: "#fff" }}
>
#ВсемПоПути
</Typography>
</Stack>
<div className="absolute bottom-[20px] -right-[520px] z-10">
<LanguageSelector onBack={onToggle} isSidebarOpen={open} />
</div>
</Box>
);
});

View File

@@ -41,6 +41,8 @@ const MapDataContext = createContext<{
latitude: number,
longitude: number
) => void;
setIconSize: (size: number) => void;
setFontSize: (size: number) => void;
saveChanges: () => void;
}>({
originalRouteData: undefined,
@@ -61,6 +63,8 @@ const MapDataContext = createContext<{
setStationOffset: () => {},
setStationAlign: () => {},
setSightCoordinates: () => {},
setIconSize: () => {},
setFontSize: () => {},
saveChanges: () => {},
});
@@ -141,7 +145,6 @@ export const MapDataProvider = observer(
}, [routeId]);
useEffect(() => {
// combine changes with original data
if (originalRouteData)
setRouteData({ ...originalRouteData, ...routeChanges });
if (originalSightData) setSightData(originalSightData);
@@ -165,9 +168,57 @@ export const MapDataProvider = observer(
});
}
function setMapCenter(x: number, y: number) {
function setIconSize(size: number) {
const clamped = Math.max(50, Math.min(300, size));
setRouteChanges((prev) => {
return { ...prev, center_latitude: x, center_longitude: y };
if (prev.icon_size === clamped) {
return prev;
}
return { ...prev, icon_size: clamped };
});
}
function setFontSize(size: number) {
const clamped = Math.max(50, Math.min(300, size));
setRouteChanges((prev) => {
if (prev.font_size === clamped) {
return prev;
}
return { ...prev, font_size: clamped };
});
}
function setMapCenter(latitude: number, longitude: number) {
const epsilon = 1e-6;
setRouteChanges((prev) => {
const prevLat = prev.center_latitude;
const prevLon = prev.center_longitude;
if (
prevLat !== undefined &&
prevLon !== undefined &&
Math.abs(prevLat - latitude) < epsilon &&
Math.abs(prevLon - longitude) < epsilon
) {
return prev;
}
return {
...prev,
center_latitude: latitude,
center_longitude: longitude,
};
});
setRouteData((routePrev) => {
if (!routePrev) return routePrev;
return {
...routePrev,
center_latitude: latitude,
center_longitude: longitude,
};
});
}
@@ -180,12 +231,42 @@ export const MapDataProvider = observer(
async function saveStationChanges() {
for (const station of stationChanges) {
await authInstance.patch(`/route/${routeId}/station`, station);
setStationData((prev) => {
const updated = { ...prev };
Object.keys(updated).forEach((lang) => {
updated[lang] = updated[lang].map((s) =>
s.id === station.station_id
? {
...s,
offset_x: station.offset_x,
offset_y: station.offset_y,
}
: s
);
});
return updated;
});
}
}
async function saveSightChanges() {
for (const sight of sightChanges) {
await authInstance.patch(`/route/${routeId}/sight`, sight);
setSightData((prev) =>
prev
? prev.map((s) =>
s.id === sight.sight_id
? {
...s,
latitude: sight.latitude,
longitude: sight.longitude,
}
: s
)
: prev
);
}
}
@@ -321,6 +402,14 @@ export const MapDataProvider = observer(
latitude: number,
longitude: number
) {
setSightData((prev) =>
prev
? prev.map((sight) =>
sight.id === sightId ? { ...sight, latitude, longitude } : sight
)
: prev
);
setSightChanges((prev) => {
const existingIndex = prev.findIndex(
(sight) => sight.sight_id === sightId
@@ -376,6 +465,8 @@ export const MapDataProvider = observer(
setStationOffset,
setStationAlign,
setSightCoordinates,
setIconSize,
setFontSize,
}),
[
originalRouteData,
@@ -388,6 +479,8 @@ export const MapDataProvider = observer(
isStationLoading,
isSightLoading,
selectedSight,
setIconSize,
setFontSize,
]
);

View File

@@ -2,7 +2,6 @@ import { Button, Stack, TextField, Typography, Slider } from "@mui/material";
import { useMapData } from "./MapDataContext";
import { useEffect, useState } from "react";
import { useTransform } from "./TransformContext";
import { coordinatesToLocal, localToCoordinates } from "./utils";
import { SCALE_FACTOR } from "./Constants";
import { toast } from "react-toastify";
@@ -13,18 +12,10 @@ export function RightSidebar() {
saveChanges,
originalRouteData,
setMapRotation,
setMapCenter,
setIconSize: updateIconSize,
setFontSize: updateFontSize,
} = useMapData();
const {
rotation,
position,
screenToLocal,
screenCenter,
rotateToAngle,
setTransform,
scale,
setScaleAtCenter,
} = useTransform();
const { rotation, rotateToAngle, scale, setScaleAtCenter } = useTransform();
const [minScale, setMinScale] = useState<number>(1);
const [maxScale, setMaxScale] = useState<number>(5);
@@ -34,14 +25,14 @@ export function RightSidebar() {
});
const [rotationDegrees, setRotationDegrees] = useState<number>(0);
const [isUserEditing, setIsUserEditing] = useState<boolean>(false);
const [iconSize, setIconSize] = useState<number>(100);
const [fontSize, setFontSize] = useState<number>(100);
useEffect(() => {
if (originalRouteData) {
// Проверяем и сбрасываем минимальный масштаб если нужно
const originalMinScale = originalRouteData.scale_min ?? 1;
const resetMinScale = originalMinScale < 1 ? 1 : originalMinScale;
// Проверяем и сбрасываем максимальный масштаб если нужно
const originalMaxScale = originalRouteData.scale_max ?? 5;
const resetMaxScale = originalMaxScale < 3 ? 3 : originalMaxScale;
@@ -52,6 +43,8 @@ export function RightSidebar() {
x: originalRouteData.center_latitude ?? 0,
y: originalRouteData.center_longitude ?? 0,
});
setIconSize(originalRouteData.icon_size ?? 100);
setFontSize(originalRouteData.font_size ?? 100);
}
}, [originalRouteData]);
@@ -72,33 +65,55 @@ export function RightSidebar() {
}, [rotationDegrees]);
useEffect(() => {
if (!isUserEditing) {
const center = screenCenter ?? { x: 0, y: 0 };
const localCenter = screenToLocal(center.x, center.y);
const coordinates = localToCoordinates(localCenter.x, localCenter.y);
setLocalCenter({ x: coordinates.latitude, y: coordinates.longitude });
if (isUserEditing) {
return;
}
}, [
position,
screenCenter,
screenToLocal,
localToCoordinates,
setLocalCenter,
isUserEditing,
]);
useEffect(() => {
setMapCenter(localCenter.x, localCenter.y);
}, [localCenter]);
const latitude = routeData?.center_latitude ?? 0;
const longitude = routeData?.center_longitude ?? 0;
setLocalCenter((prev) => {
if (
Math.abs(prev.x - latitude) < 1e-6 &&
Math.abs(prev.y - longitude) < 1e-6
) {
return prev;
}
return { x: latitude, y: longitude };
});
}, [isUserEditing, routeData?.center_latitude, routeData?.center_longitude]);
function setRotationFromDegrees(degrees: number) {
rotateToAngle((degrees * Math.PI) / 180);
}
function pan({ x, y }: { x: number; y: number }) {
const coordinates = coordinatesToLocal(x, y);
setTransform(coordinates.x, coordinates.y);
}
const handleIconSizeChange = (value: number) => {
if (!Number.isFinite(value)) {
return;
}
const clamped = Math.max(50, Math.min(300, Math.round(value)));
setIconSize(clamped);
updateIconSize(clamped);
};
const handleFontSizeChange = (value: number) => {
if (!Number.isFinite(value)) {
return;
}
const clamped = Math.max(50, Math.min(300, Math.round(value)));
setFontSize(clamped);
updateFontSize(clamped);
};
useEffect(() => {
const next = routeData?.icon_size ?? originalRouteData?.icon_size ?? 100;
setIconSize((prev) => (Math.abs(prev - next) > 0.5 ? next : prev));
}, [routeData?.icon_size, originalRouteData?.icon_size]);
useEffect(() => {
const next = routeData?.font_size ?? originalRouteData?.font_size ?? 100;
setFontSize((prev) => (Math.abs(prev - next) > 0.5 ? next : prev));
}, [routeData?.font_size, originalRouteData?.font_size]);
if (!routeData) {
return null;
@@ -118,7 +133,7 @@ export function RightSidebar() {
borderRadius={2}
>
<Typography variant="h6" sx={{ mb: 2, color: "#fff" }} textAlign="center">
Детали о достопримечательностях
Настройка маршрута
</Typography>
<Stack spacing={2} direction="row" alignItems="center">
@@ -130,19 +145,18 @@ export function RightSidebar() {
onChange={(e) => {
let newMinScale = Number(e.target.value);
// Сбрасываем к 1 если меньше
if (newMinScale < 1) {
newMinScale = 1;
if (newMinScale < 10) {
newMinScale = 10;
}
setMinScale(newMinScale);
if (maxScale - newMinScale < 2) {
let newMaxScale = newMinScale + 2;
// Сбрасываем максимальный к 3 если меньше минимального
if (newMaxScale < 3) {
newMaxScale = 3;
setMinScale(1); // Сбрасываем минимальный к 1
setMinScale(1);
}
setMaxScale(newMaxScale);
}
@@ -175,19 +189,22 @@ export function RightSidebar() {
onChange={(e) => {
let newMaxScale = Number(e.target.value);
// Сбрасываем к 3 если меньше минимального
if (newMaxScale < 3) {
newMaxScale = 3;
if (newMaxScale < 13) {
newMaxScale = 13;
}
if (newMaxScale > 300) {
newMaxScale = 300;
}
setMaxScale(newMaxScale);
if (newMaxScale - minScale < 2) {
let newMinScale = newMaxScale - 2;
// Сбрасываем минимальный к 1 если меньше
if (newMinScale < 1) {
newMinScale = 1;
setMaxScale(3); // Сбрасываем максимальный к минимальному значению
setMaxScale(3);
}
setMinScale(newMinScale);
}
@@ -208,7 +225,7 @@ export function RightSidebar() {
slotProps={{
input: {
min: 3,
max: 10,
max: 300,
},
}}
/>
@@ -272,6 +289,62 @@ export function RightSidebar() {
}}
/>
<Typography variant="body2" sx={{ color: "#fff", textAlign: "center" }}>
Размер иконок: {iconSize}%
</Typography>
<Slider
value={iconSize}
onChange={(_, value) => {
if (typeof value === "number") {
handleIconSizeChange(value);
}
}}
min={50}
max={300}
step={1}
sx={{
color: "#fff",
"& .MuiSlider-thumb": {
backgroundColor: "#fff",
},
"& .MuiSlider-track": {
backgroundColor: "#fff",
},
"& .MuiSlider-rail": {
backgroundColor: "#666",
},
}}
/>
<Typography variant="body2" sx={{ color: "#fff", textAlign: "center" }}>
Размер шрифта: {fontSize}%
</Typography>
<Slider
value={fontSize}
onChange={(_, value) => {
if (typeof value === "number") {
handleFontSizeChange(value);
}
}}
min={50}
max={300}
step={1}
sx={{
color: "#fff",
"& .MuiSlider-thumb": {
backgroundColor: "#fff",
},
"& .MuiSlider-track": {
backgroundColor: "#fff",
},
"& .MuiSlider-rail": {
backgroundColor: "#666",
},
}}
/>
<TextField
type="number"
label="Поворот (в градусах)"
@@ -314,9 +387,10 @@ export function RightSidebar() {
onChange={(e) => {
setIsUserEditing(true);
setLocalCenter((prev) => ({ ...prev, x: Number(e.target.value) }));
pan({ x: Number(e.target.value), y: localCenter.y });
}}
onBlur={() => setIsUserEditing(false)}
onBlur={() => {
setIsUserEditing(false);
}}
style={{ backgroundColor: "#222", borderRadius: 4 }}
sx={{
"& .MuiInputLabel-root": {
@@ -338,9 +412,10 @@ export function RightSidebar() {
onChange={(e) => {
setIsUserEditing(true);
setLocalCenter((prev) => ({ ...prev, y: Number(e.target.value) }));
pan({ x: localCenter.x, y: Number(e.target.value) });
}}
onBlur={() => setIsUserEditing(false)}
onBlur={() => {
setIsUserEditing(false);
}}
style={{ backgroundColor: "#222", borderRadius: 4 }}
sx={{
"& .MuiInputLabel-root": {

View File

@@ -79,7 +79,7 @@ export const Sight = ({ sight, id }: Readonly<SightProps>) => {
const [texture, setTexture] = useState(Texture.EMPTY);
useEffect(() => {
Assets.load("/SightIcon.png").then(setTexture);
Assets.load("/sight_icon.svg").then(setTexture);
}, []);
useEffect(() => {}, [id, sight.latitude, sight.longitude]);

View File

@@ -2,7 +2,6 @@ import { FederatedMouseEvent, Graphics } from "pixi.js";
import { useCallback, useState, useEffect, useRef, FC, useMemo } from "react";
import { observer } from "mobx-react-lite";
// --- Заглушки для зависимостей (замените на ваши реальные импорты) ---
import {
BACKGROUND_COLOR,
PATH_COLOR,
@@ -15,22 +14,16 @@ import { StationData } from "./types";
import { useMapData } from "./MapDataContext";
import { coordinatesToLocal } from "./utils";
import { languageStore } from "@shared";
// --- Конец заглушек ---
// --- Декларации для react-pixi ---
// (В реальном проекте типы должны быть предоставлены библиотекой @pixi/react-pixi)
declare const pixiContainer: any;
declare const pixiGraphics: any;
declare const pixiText: any;
// --- Типы ---
type HorizontalAlign = "left" | "center" | "right";
type VerticalAlign = "top" | "center" | "bottom";
type TextAlign = HorizontalAlign | `${HorizontalAlign} ${VerticalAlign}`;
type LabelAlign = "left" | "center" | "right";
// --- Утилиты ---
/**
* Преобразует текстовое позиционирование в anchor координаты.
*/
@@ -39,8 +32,6 @@ type LabelAlign = "left" | "center" | "right";
* Получает координату anchor.x из типа выравнивания.
*/
// --- Интерфейсы пропсов ---
interface StationProps {
station: StationData;
ruLabel: string | null;
@@ -83,10 +74,6 @@ const getAnchorFromOffset = (
return { x: (1 - nx) / 2, y: (1 - ny) / 2 };
};
// =========================================================================
// Компонент: Панель управления выравниванием в стиле УрФУ
// =========================================================================
const LabelAlignmentControl: FC<LabelAlignmentControlProps> = ({
scale,
currentAlign,
@@ -107,7 +94,6 @@ const LabelAlignmentControl: FC<LabelAlignmentControlProps> = ({
(g: Graphics) => {
g.clear();
// Основной фон с градиентом
g.roundRect(
-controlWidth / 2,
0,
@@ -115,9 +101,8 @@ const LabelAlignmentControl: FC<LabelAlignmentControlProps> = ({
controlHeight,
borderRadius
);
g.fill({ color: "#1a1a1a" }); // Темный фон как у УрФУ
g.fill({ color: "#1a1a1a" });
// Тонкая рамка
g.roundRect(
-controlWidth / 2,
0,
@@ -127,7 +112,6 @@ const LabelAlignmentControl: FC<LabelAlignmentControlProps> = ({
);
g.stroke({ color: "#333333", width: strokeWidth });
// Разделители между кнопками
for (let i = 1; i < 3; i++) {
const x = -controlWidth / 2 + buttonWidth * i;
g.moveTo(x, strokeWidth);
@@ -151,7 +135,7 @@ const LabelAlignmentControl: FC<LabelAlignmentControlProps> = ({
controlHeight - strokeWidth * 2,
borderRadius / 2
);
g.fill({ color: "#0066cc", alpha: 0.8 }); // Синий акцент УрФУ
g.fill({ color: "#0066cc", alpha: 0.8 });
}
},
[controlWidth, controlHeight, buttonWidth, strokeWidth, borderRadius]
@@ -230,10 +214,6 @@ const LabelAlignmentControl: FC<LabelAlignmentControlProps> = ({
);
};
// =========================================================================
// Компонент: Метка Станции (с логикой)
// =========================================================================
const StationLabel = observer(
({
station,
@@ -274,48 +254,45 @@ const StationLabel = observer(
hideTimer.current = null;
}
setIsHovered(true);
onTextHover?.(true); // Call the callback to indicate text is hovered
onTextHover?.(true);
};
const handleControlPointerEnter = () => {
// Дополнительная обработка для панели управления
if (hideTimer.current) {
clearTimeout(hideTimer.current);
hideTimer.current = null;
}
setIsControlHovered(true);
setIsHovered(true);
onTextHover?.(true); // Call the callback to indicate text/control is hovered
onTextHover?.(true);
};
const handleControlPointerLeave = () => {
setIsControlHovered(false);
// Если курсор не над основным контейнером, скрываем панель через некоторое время
if (!isHovered) {
hideTimer.current = setTimeout(() => {
setIsHovered(false);
onTextHover?.(false); // Call the callback to indicate neither text nor control is hovered
onTextHover?.(false);
}, 0);
}
};
const handlePointerLeave = () => {
// Увеличиваем время до скрытия панели и добавляем проверку
hideTimer.current = setTimeout(() => {
setIsHovered(false);
// Если курсор не над панелью управления, скрываем и её
if (!isControlHovered) {
setIsControlHovered(false);
}
onTextHover?.(false); // Call the callback to indicate text is no longer hovered
}, 100); // Увеличиваем время до скрытия панели
onTextHover?.(false);
}, 100);
};
useEffect(() => {
setPosition({ x: station.offset_x ?? 0, y: station.offset_y ?? 0 });
}, [station.offset_x, station.offset_y, station.id]);
// Функция для конвертации числового align в строковый
const convertNumericAlign = (align: number): LabelAlign => {
switch (align) {
case 0:
@@ -329,7 +306,6 @@ const StationLabel = observer(
}
};
// Функция для конвертации строкового align в числовой
const convertStringAlign = (align: LabelAlign): number => {
switch (align) {
case "left":
@@ -353,7 +329,6 @@ const StationLabel = observer(
const compensatedRuFontSize = (26 * 0.75) / scale;
const compensatedNameFontSize = (16 * 0.75) / scale;
// Измеряем ширину верхнего лейбла
useEffect(() => {
if (ruLabelRef.current && ruLabel) {
setRuLabelWidth(ruLabelRef.current.width);
@@ -386,7 +361,6 @@ const StationLabel = observer(
y: dragStartPos.current.y + dy_screen,
};
// Проверяем, изменилась ли позиция
if (
Math.abs(newPosition.x - position.x) > 0.01 ||
Math.abs(newPosition.y - position.y) > 0.01
@@ -406,7 +380,7 @@ const StationLabel = observer(
const handleAlignChange = async (align: LabelAlign) => {
setCurrentLabelAlign(align);
onLabelAlignChange?.(align);
// Сохраняем в стор
const numericAlign = convertStringAlign(align);
setStationAlign(station.id, numericAlign);
};
@@ -416,34 +390,29 @@ const StationLabel = observer(
[position.x, position.y]
);
// Функция для расчета позиции нижнего лейбла относительно ширины верхнего
const getSecondLabelPosition = (): number => {
if (!ruLabelWidth) return 0;
switch (currentLabelAlign) {
case "left":
// Позиционируем относительно левого края верхнего текста
return -ruLabelWidth / 2;
case "center":
// Центрируем относительно центра верхнего текста
return 0;
case "right":
// Позиционируем относительно правого края верхнего текста
return ruLabelWidth / 2;
default:
return 0;
}
};
// Функция для расчета anchor нижнего лейбла
const getSecondLabelAnchor = (): number => {
switch (currentLabelAlign) {
case "left":
return 0; // anchor.x = 0 (левый край)
return 0;
case "center":
return 0.5; // anchor.x = 0.5 (центр)
return 0.5;
case "right":
return 1; // anchor.x = 1 (правый край)
return 1;
default:
return 0.5;
}
@@ -522,10 +491,6 @@ const StationLabel = observer(
}
);
// =========================================================================
// Главный экспортируемый компонент: Станция
// =========================================================================
export const Station = ({
station,
ruLabel,
@@ -548,10 +513,9 @@ export const Station = ({
g.circle(coordinates.x * UP_SCALE, coordinates.y * UP_SCALE, radius);
// Change fill color when text is hovered
if (isTextHovered) {
g.fill({ color: 0x00aaff }); // Highlight color when hovered
g.stroke({ color: 0xffffff, width: strokeWidth + 1 }); // Brighter outline when hovered
g.fill({ color: 0x00aaff });
g.stroke({ color: 0xffffff, width: strokeWidth + 1 });
} else {
g.fill({ color: PATH_COLOR });
g.stroke({ color: BACKGROUND_COLOR, width: strokeWidth });

View File

@@ -50,7 +50,6 @@ const TransformContext = createContext<{
setScaleAtCenter: () => {},
});
// Provider component
export const TransformProvider = ({ children }: { children: ReactNode }) => {
const [position, setPosition] = useState({ x: 0, y: 0 });
const [scale, setScale] = useState(1);
@@ -59,12 +58,10 @@ export const TransformProvider = ({ children }: { children: ReactNode }) => {
const screenToLocal = useCallback(
(screenX: number, screenY: number) => {
// Translate point relative to current pan position
const translatedX = (screenX - position.x) / scale;
const translatedY = (screenY - position.y) / scale;
// Rotate point around center
const cosRotation = Math.cos(-rotation); // Negative rotation to reverse transform
const cosRotation = Math.cos(-rotation);
const sinRotation = Math.sin(-rotation);
const rotatedX = translatedX * cosRotation - translatedY * sinRotation;
const rotatedY = translatedX * sinRotation + translatedY * cosRotation;
@@ -77,7 +74,6 @@ export const TransformProvider = ({ children }: { children: ReactNode }) => {
[position.x, position.y, scale, rotation]
);
// Inverse of screenToLocal
const localToScreen = useCallback(
(localX: number, localY: number) => {
const upscaledX = localX * UP_SCALE;
@@ -120,7 +116,6 @@ export const TransformProvider = ({ children }: { children: ReactNode }) => {
(currentFromPosition.x - center.x) * sinDelta,
};
// Update both rotation and position in a single batch to avoid stale closure
setRotation(to);
setPosition(newPosition);
},
@@ -150,13 +145,11 @@ export const TransformProvider = ({ children }: { children: ReactNode }) => {
const cosRot = Math.cos(selectedRotation);
const sinRot = Math.sin(selectedRotation);
// Translate point relative to center, rotate, then translate back
const dx = newPosition.x;
const dy = newPosition.y;
newPosition.x = dx * cosRot - dy * sinRot + center.x;
newPosition.y = dx * sinRot + dy * cosRot + center.y;
// Batch state updates to avoid intermediate renders
setPosition(newPosition);
setRotation(selectedRotation);
setScale(selectedScale);
@@ -184,7 +177,6 @@ export const TransformProvider = ({ children }: { children: ReactNode }) => {
);
const setScaleOnly = useCallback((newScale: number) => {
// Изменяем только масштаб, не трогая позицию и поворот
setScale(newScale);
}, []);
@@ -237,7 +229,6 @@ export const TransformProvider = ({ children }: { children: ReactNode }) => {
);
};
// Custom hook for easy access to transform values
export const useTransform = () => {
const context = useContext(TransformContext);
if (!context) {

View File

@@ -1,6 +1,6 @@
import { useRef, useEffect, useState } from "react";
import { Widgets } from "./Widgets";
import { Application, extend } from "@pixi/react";
import { extend } from "@pixi/react";
import {
Container,
Graphics,
@@ -9,24 +9,18 @@ import {
TilingSprite,
Text,
} from "pixi.js";
import { Stack } from "@mui/material";
import { Box, Stack } from "@mui/material";
import { MapDataProvider, useMapData } from "./MapDataContext";
import { TransformProvider, useTransform } from "./TransformContext";
import { InfiniteCanvas } from "./InfiniteCanvas";
import { TravelPath } from "./TravelPath";
import { LeftSidebar } from "./LeftSidebar";
import { RightSidebar } from "./RightSidebar";
import { coordinatesToLocal } from "./utils";
import { LanguageSwitcher } from "@widgets";
import { languageStore } from "@shared";
import { observer } from "mobx-react-lite";
import { Sight } from "./Sight";
import { SightData } from "./types";
import { Station } from "./Station";
import { UP_SCALE } from "./Constants";
import CircularProgress from "@mui/material/CircularProgress";
import { WebGLRouteMapPrototype } from "./webgl-prototype/WebGLRouteMapPrototype";
import { CircularProgress } from "@mui/material";
extend({
Container,
@@ -42,7 +36,7 @@ const Loading = () => {
if (isRouteLoading || isStationLoading || isSightLoading) {
return (
<div className="fixed flex z-1 items-center justify-center h-screen w-screen bg-[#111]">
<div className="fixed flex z-1000000000 items-center justify-center h-screen w-screen bg-[#111]">
<CircularProgress />
</div>
);
@@ -51,14 +45,33 @@ const Loading = () => {
return null;
};
export const RoutePreview = () => {
const { routeData, stationData, sightData } = useMapData();
const [isLeftSidebarOpen, setIsLeftSidebarOpen] = useState(true);
return (
<MapDataProvider>
<TransformProvider>
<Stack direction="row" height="100vh" width="100vw" overflow="hidden">
{routeData && stationData && sightData ? <LanguageSwitcher /> : null}
<Loading />
<LeftSidebar />
<Box
sx={{
position: "relative",
width: isLeftSidebarOpen ? 300 : 0,
transition: "width 0.3s ease",
overflow: "visible",
height: "100%",
bgcolor: "primary.main",
borderRight: isLeftSidebarOpen
? "1px solid rgba(255,255,255,0.08)"
: "none",
display: "flex",
justifyContent: "flex-start",
flexShrink: 0,
}}
>
<LeftSidebar
open={isLeftSidebarOpen}
onToggle={() => setIsLeftSidebarOpen((prev) => !prev)}
/>
</Box>
<Stack direction="row" flex={1} position="relative" height="100%">
<RouteMap />
<Widgets />
@@ -71,15 +84,8 @@ export const RoutePreview = () => {
};
export const RouteMap = observer(() => {
const { language } = languageStore;
const { setPosition, setTransform, screenCenter } = useTransform();
const {
routeData,
stationData,
sightData,
originalRouteData,
originalSightData,
} = useMapData();
const { routeData, stationData, sightData, originalRouteData } = useMapData();
const [points, setPoints] = useState<{ x: number; y: number }[]>([]);
const [isSetup, setIsSetup] = useState(false);
@@ -165,8 +171,7 @@ export const RouteMap = observer(() => {
return (
<div style={{ width: "100%", height: "100%" }} ref={parentRef}>
<LanguageSwitcher />
<Application resizeTo={parentRef} background="#fff" preference="webgl">
{/* <Application resizeTo={parentRef} background="#000" preference="webgl">
<InfiniteCanvas>
<TravelPath points={points} />
{stationData[language].map((obj, index) => (
@@ -184,7 +189,8 @@ export const RouteMap = observer(() => {
return <Sight sight={sight} id={index} key={sight.id} />;
})}
</InfiniteCanvas>
</Application>
</Application> */}
<WebGLRouteMapPrototype />
</div>
);
});

View File

@@ -3,6 +3,8 @@ export interface RouteData {
carrier_id: number;
center_latitude: number;
center_longitude: number;
icon_size: number;
font_size: number;
governor_appeal: number;
id: number;
path: [number, number][];

View File

@@ -0,0 +1,203 @@
import { useEffect, useRef, useState, type ReactElement } from "react";
import { observer } from "mobx-react-lite";
import { languageStore } from "@shared";
const LANGUAGES = ["ru", "zh", "en"] as const;
type Language = (typeof LANGUAGES)[number];
type LanguageSelectorProps = {
onBack?: () => void;
isSidebarOpen?: boolean;
};
const renderLanguageIcon = (lang: Language): ReactElement => {
switch (lang) {
case "ru":
return (
<svg
className="h-12 w-12 cursor-pointer"
viewBox="0 0 48 48"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect x="3" y="3" width="42" height="42" rx="21" fill="black" />
<path
d="M24 0C10.75 0 0 10.75 0 24C0 37.25 10.75 48 24 48C37.25 48 48 37.25 48 24C48 10.75 37.25 0 24 0ZM24.2 33.55H19.92L16.29 26.46H13.11V33.55H9.12V14.18H16.32C18.61 14.18 20.37 14.69 21.62 15.71C22.87 16.73 23.48 18.17 23.48 20.03C23.48 21.35 23.19 22.45 22.62 23.34C22.05 24.22 21.18 24.93 20.02 25.45L24.21 33.37V33.56L24.2 33.55ZM40.3 26.94C40.3 29.06 39.64 30.74 38.31 31.97C36.98 33.2 35.17 33.82 32.87 33.82C30.57 33.82 28.81 33.22 27.48 32.02C26.15 30.82 25.47 29.18 25.44 27.08V14.18H29.43V26.97C29.43 28.24 29.73 29.16 30.34 29.74C30.95 30.32 31.79 30.61 32.86 30.61C35.1 30.61 36.24 29.43 36.28 27.07V14.18H40.28V26.94H40.3Z"
fill="white"
/>
<path
d="M16.3086 17.4099H13.0986V23.2199H16.3186C17.3186 23.2199 18.0986 22.9599 18.6486 22.4499C19.1986 21.9399 19.4686 21.2399 19.4686 20.3399C19.4686 19.4399 19.2086 18.7099 18.6886 18.1799C18.1686 17.6499 17.3686 17.3899 16.2986 17.3899L16.3086 17.4099Z"
fill="white"
/>
</svg>
);
case "zh":
return (
<svg
className="h-12 w-12 cursor-pointer"
viewBox="0 0 48 48"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect x="3" y="3" width="42" height="42" rx="21" fill="black" />
<path d="M10.287 20.382H6.291V24.147H10.287V20.382Z" fill={"white"} />
<path
d="M13.704 24.147H17.721V20.382H13.704V24.147Z"
fill={"white"}
/>
<path
d="M36.1254 20.046H29.8575C30.6606 21.9406 31.7187 23.6442 33.0513 25.1217C34.3105 23.6927 35.3315 22.0126 36.1254 20.046Z"
fill={"white"}
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M24 48C37.2548 48 48 37.2548 48 24C48 10.7452 37.2548 0 24 0C10.7452 0 0 10.7452 0 24C0 37.2548 10.7452 48 24 48ZM10.287 13.5H13.704V17.1541H21.117V28.446H17.721V27.375H13.704V33.969H10.287V27.375H6.291V28.5511H3V17.1541H10.287V13.5ZM31.35 13.5H34.704V16.8181H43.083V20.046H39.8887C38.804 22.9506 37.3746 25.3834 35.581 27.4065C37.6488 28.9237 40.1651 30.0542 43.1682 30.7291L43.8469 30.8817L43.3465 31.3649C42.7753 31.9162 41.9777 33.0771 41.5886 33.7939L41.4484 34.0521L41.1642 33.9778C37.8385 33.1088 35.1249 31.7521 32.8974 29.9253C30.6296 31.6954 27.9389 33.0335 24.802 34.015L24.4889 34.1129L24.3502 33.8156C24.0724 33.2203 23.2933 32.029 22.8051 31.439L22.4307 30.9868L22.9986 30.8373C25.936 30.0648 28.4025 28.9702 30.4373 27.4935C28.6775 25.4061 27.319 22.9142 26.2412 20.046H23.097V16.8181H31.35V13.5Z"
fill={"white"}
/>
</svg>
);
case "en":
default:
return (
<svg
className="h-12 w-12 cursor-pointer"
viewBox="0 0 48 48"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect x="3" y="3" width="42" height="42" rx="21" fill="black" />
<path
d="M24 0C10.75 0 0 10.75 0 24C0 37.25 10.75 48 24 48C37.25 48 48 37.25 48 24C48 10.75 37.25 0 24 0ZM21.57 33.79H8.41V14.15H21.55V17.43H12.45V22.11H20.22V25.28H12.45V30.54H21.57V33.79ZM39.54 33.79H35.49L27.61 20.87V33.79H23.56V14.15H27.61L35.5 27.1V14.15H39.53V33.79H39.54Z"
fill="white"
/>
</svg>
);
}
};
const CollapsedIcon = () => (
<svg
className="h-12 w-12 cursor-pointer"
viewBox="0 0 48 48"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect x="4" y="3" width="39" height="42" rx="19.5" fill="black" />
<path
fillRule="evenodd"
clipRule="evenodd"
d="M0 24C0 10.75 10.75 0 24 0C37.25 0 48 10.75 48 24C48 37.25 37.25 48 24 48C10.75 48 0 37.25 0 24ZM36.05 19.41L38.27 25.47L40.47 19.39L39.17 19.77C39.16 19.75 39.16 19.35 39.16 19.32C38.58 12.41 32.99 7.15 25.94 7.15C25.42 7.15 24.99 7.58 24.99 8.1C24.99 8.62 25.42 9.05 25.94 9.05C31.92 9.05 36.68 13.47 37.26 19.31C37.26 19.325 37.2625 19.435 37.265 19.545C37.2675 19.655 37.27 19.765 37.27 19.78L36.05 19.41ZM8.90375 27.5369C11.3535 26.9568 13.6332 25.8438 15.5701 24.2838L15.5709 24.2846C17.2124 25.8526 19.2497 26.9784 21.4812 27.5521C21.5875 27.5649 21.6945 27.5649 21.8007 27.5521C22.3194 27.6074 22.8298 27.3911 23.1385 26.9848C23.448 26.5786 23.5086 26.0441 23.2986 25.5826C23.0879 25.1211 22.6389 24.803 22.1202 24.7477C20.4804 24.3182 18.9808 23.4937 17.7634 22.3495C20.2489 20.0131 22.1036 17.1245 23.1659 13.9363C23.2729 13.5077 23.165 13.0566 22.8754 12.716C22.6016 12.3627 22.1709 12.1552 21.7136 12.1552H16.6307V10.402C16.6307 9.90121 16.3535 9.43889 15.9045 9.1881C15.4556 8.9373 14.9012 8.9373 14.4522 9.1881C14.0033 9.43809 13.7261 9.90121 13.7261 10.402V12.1824H8.64317C8.12367 12.1824 7.64484 12.45 7.38509 12.8835C7.12534 13.317 7.12534 13.8522 7.38509 14.2857C7.64484 14.7192 8.1245 14.9868 8.64317 14.9868H19.6655C18.6804 16.9971 17.3443 18.828 15.7153 20.3993C14.8373 19.3977 14.0688 18.3128 13.4207 17.1598C13.2747 16.7896 12.9734 16.4972 12.5909 16.3529C12.2083 16.2087 11.7809 16.2279 11.4141 16.405C11.0473 16.5821 10.7751 16.901 10.6647 17.2824C10.5544 17.6638 10.6166 18.0724 10.8357 18.4074C11.6058 19.7423 12.5153 20.997 13.551 22.1516C12.0207 23.3728 10.2307 24.2533 8.30873 24.7317C7.92284 24.7709 7.56932 24.956 7.32617 25.2469C7.08219 25.5369 6.96767 25.9095 7.00833 26.2813C7.04899 26.6539 7.24069 26.9952 7.54193 27.23C7.84235 27.4656 8.22824 27.5761 8.61329 27.5369C8.70956 27.5513 8.80748 27.5513 8.90375 27.5369ZM34.9002 38.8803C35.2512 39.0301 35.6487 39.0397 36.0072 38.9067C36.3815 38.7865 36.6886 38.5237 36.8587 38.18C37.0288 37.8362 37.0462 37.4404 36.9077 37.0839L30.3434 20.7767C30.238 20.5131 30.0529 20.2863 29.8115 20.1261C29.57 19.9658 29.2845 19.8793 28.9924 19.8785C28.7019 19.8785 28.4173 19.9626 28.1766 20.1196C27.9359 20.2775 27.7501 20.501 27.6422 20.7623L21.136 36.523C20.9443 36.9885 21.024 37.5181 21.346 37.9116C21.668 38.305 22.1825 38.5029 22.6962 38.4308C23.2107 38.3579 23.6455 38.0269 23.8372 37.5606L25.4057 33.6489H32.3185L34.1342 38.1079C34.2737 38.4524 34.5492 38.7304 34.9002 38.8803ZM28.9052 25.1652L31.1857 30.8445H31.1849H26.5667L28.9052 25.1652Z"
fill="#FFFFFF"
/>
</svg>
);
const ArrowIcon = ({ rotation }: { rotation: number }) => (
<svg
style={{
transform: `rotate(${rotation}deg)`,
transition: "transform 0.15s ease",
}}
className="h-12 w-12"
viewBox="0 0 48 48"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect x="8" y="7" width="31" height="33" fill="black" />
<path
d="M24.0001 0C10.7501 0 0.00012207 10.75 0.00012207 24C0.00012207 37.25 10.7501 48 24.0001 48C37.2501 48 48.0001 37.25 48.0001 24C48.0001 10.75 37.2501 0 24.0001 0ZM37.5401 25.84C37.5401 26.4 37.0901 26.85 36.5301 26.85H20.5901C20.1401 26.85 19.9201 27.39 20.2301 27.71L27.6801 35.16C28.0801 35.56 28.0801 36.2 27.6801 36.59L25.0801 39.19C24.6801 39.59 24.0401 39.59 23.6501 39.19L12.4901 28.03L9.17012 24.71C8.77012 24.31 8.77012 23.67 9.17012 23.28L12.4901 19.96L23.6501 8.8C24.0501 8.4 24.6901 8.4 25.0801 8.8L27.6801 11.4C28.0801 11.8 28.0801 12.44 27.6801 12.83L20.2301 20.28C19.9101 20.6 20.1401 21.14 20.5901 21.14H36.5301C37.0901 21.14 37.5401 21.59 37.5401 22.15V25.82V25.84Z"
fill="white"
/>
</svg>
);
const LanguageSelector = observer(
({ onBack, isSidebarOpen = true }: LanguageSelectorProps) => {
const { setLanguage } = languageStore;
const [isOpen, setIsOpen] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!isOpen) {
return;
}
const handleOutside = (event: PointerEvent) => {
if (
containerRef.current &&
!containerRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
};
document.addEventListener("pointerdown", handleOutside);
return () => {
document.removeEventListener("pointerdown", handleOutside);
};
}, [isOpen]);
const handleSelect = (code: Language) => {
setLanguage(code);
setIsOpen(false);
};
const toggle = () => setIsOpen((prev) => !prev);
return (
<div
ref={containerRef}
className="pointer-events-auto"
style={{
width: "500px",
transition: "width 0.25s ease",
flex: "0 0 auto",
}}
>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2 ">
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onBack?.();
}}
className="flex h-12 w-12 items-center justify-center"
aria-label={
isOpen ? "Скрыть выбор языка" : "Показать выбор языка"
}
>
<ArrowIcon rotation={isSidebarOpen ? 0 : 180} />
</button>
{isOpen ? (
LANGUAGES.map((lang) => (
<button
key={lang}
type="button"
onClick={(event) => {
event.stopPropagation();
handleSelect(lang);
}}
className="flex h-12 w-12 items-center justify-center"
aria-label={`Переключить язык на ${lang.toUpperCase()}`}
>
{renderLanguageIcon(lang)}
</button>
))
) : (
<div
className="flex h-12 w-12 items-center justify-center"
onClick={toggle}
>
<CollapsedIcon />
</div>
)}
</div>
</div>
</div>
);
}
);
export default LanguageSelector;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,627 @@
import { useState, useEffect } from "react";
import {
Stack,
Typography,
Accordion,
AccordionSummary,
AccordionDetails,
useTheme,
TextField,
Autocomplete,
TableCell,
TableContainer,
Table,
TableHead,
TableRow,
Paper,
TableBody,
Checkbox,
FormControlLabel,
Tabs,
Tab,
Box,
} from "@mui/material";
import { observer } from "mobx-react-lite";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import {
AnimatedCircleButton,
authInstance,
languageStore,
selectedCityStore,
} from "@shared";
type Field<T> = {
label: string;
data: keyof T;
render?: (value: any) => React.ReactNode;
};
type LinkedStationsProps<T> = {
parentId: string | number;
fields: Field<T>[];
setItemsParent?: (items: T[]) => void;
type: "show" | "edit";
onUpdate?: () => void;
disableCreation?: boolean;
updatedLinkedItems?: T[];
refresh?: number;
};
export const LinkedStations = <
T extends { id: number; name: string; [key: string]: any }
>(
props: LinkedStationsProps<T>
) => {
const theme = useTheme();
return (
<>
<Accordion sx={{ width: "100%" }}>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
sx={{
background: theme.palette.background.paper,
borderBottom: `1px solid ${theme.palette.divider}`,
width: "100%",
}}
>
<Typography variant="subtitle1" fontWeight="bold">
Привязанные остановки
</Typography>
</AccordionSummary>
<AccordionDetails
sx={{ background: theme.palette.background.paper, width: "100%" }}
>
<Stack gap={2} width="100%">
<LinkedStationsContents {...props} />
</Stack>
</AccordionDetails>
</Accordion>
</>
);
};
const LinkedStationsContentsInner = <
T extends { id: number; name: string; [key: string]: any }
>({
parentId,
setItemsParent,
fields,
type,
onUpdate,
disableCreation = false,
updatedLinkedItems,
refresh,
}: LinkedStationsProps<T>) => {
const { language } = languageStore;
const [allItems, setAllItems] = useState<T[]>([]);
const [linkedItems, setLinkedItems] = useState<T[]>([]);
const [selectedItemId, setSelectedItemId] = useState<number | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState(0);
const [selectedItems, setSelectedItems] = useState<Set<number>>(new Set());
const [searchQuery, setSearchQuery] = useState("");
const [selectedToDetach, setSelectedToDetach] = useState<Set<number>>(
new Set()
);
const [isLinkingSingle, setIsLinkingSingle] = useState(false);
const [isLinkingBulk, setIsLinkingBulk] = useState(false);
const [isBulkDetaching, setIsBulkDetaching] = useState(false);
const [detachingIds, setDetachingIds] = useState<Set<number>>(new Set());
useEffect(() => {}, [error]);
const parentResource = "sight";
const childResource = "station";
const availableItems = allItems
.filter((item) => !linkedItems.some((linked) => linked.id === item.id))
.filter((item) => {
const selectedCityId = selectedCityStore.selectedCityId;
if (selectedCityId && "city_id" in item) {
return item.city_id === selectedCityId;
}
return true;
})
.sort((a, b) => a.name.localeCompare(b.name));
const filteredAvailableItems = availableItems.filter((item) => {
if (!searchQuery.trim()) return true;
return String(item.name).toLowerCase().includes(searchQuery.toLowerCase());
});
useEffect(() => {
if (updatedLinkedItems) {
setLinkedItems(updatedLinkedItems);
}
}, [updatedLinkedItems]);
useEffect(() => {
setItemsParent?.(linkedItems);
}, [linkedItems, setItemsParent]);
useEffect(() => {
setSelectedToDetach((prev) => {
const updated = new Set<number>();
linkedItems.forEach((item) => {
if (prev.has(item.id)) {
updated.add(item.id);
}
});
return updated;
});
}, [linkedItems]);
const linkItem = () => {
if (selectedItemId !== null) {
setError(null);
const requestData = {
station_id: selectedItemId,
};
setIsLinkingSingle(true);
authInstance
.post(`/${parentResource}/${parentId}/${childResource}`, requestData)
.then(() => {
const newItem = allItems.find((item) => item.id === selectedItemId);
if (newItem) {
setLinkedItems([...linkedItems, newItem]);
}
setSelectedItemId(null);
onUpdate?.();
})
.catch((error) => {
console.error("Error linking station:", error);
setError("Failed to link station");
})
.finally(() => {
setIsLinkingSingle(false);
});
}
};
const deleteItem = (itemId: number) => {
setError(null);
setDetachingIds((prev) => {
const next = new Set(prev);
next.add(itemId);
return next;
});
authInstance
.delete(`/${parentResource}/${parentId}/${childResource}`, {
data: { [`${childResource}_id`]: itemId },
})
.then(() => {
setLinkedItems(linkedItems.filter((item) => item.id !== itemId));
onUpdate?.();
})
.catch((error) => {
console.error("Error deleting station:", error);
setError("Failed to delete station");
})
.finally(() => {
setDetachingIds((prev) => {
const next = new Set(prev);
next.delete(itemId);
return next;
});
});
};
const handleCheckboxChange = (itemId: number) => {
const updated = new Set(selectedItems);
if (updated.has(itemId)) {
updated.delete(itemId);
} else {
updated.add(itemId);
}
setSelectedItems(updated);
};
const handleBulkLink = async () => {
if (selectedItems.size === 0) return;
setError(null);
setIsLinkingBulk(true);
const idsToLink = Array.from(selectedItems);
const linkedIds: number[] = [];
const failedIds: number[] = [];
for (const id of idsToLink) {
try {
await authInstance.post(`/${parentResource}/${parentId}/${childResource}`, {
station_id: id,
});
linkedIds.push(id);
} catch (error) {
console.error("Error linking station:", error);
failedIds.push(id);
}
}
if (linkedIds.length > 0) {
const newItems = allItems.filter((item) => linkedIds.includes(item.id));
setLinkedItems((prev) => {
const existingIds = new Set(prev.map((item) => item.id));
const additions = newItems.filter((item) => !existingIds.has(item.id));
return [...prev, ...additions];
});
onUpdate?.();
}
setSelectedItems((prev) => {
if (linkedIds.length === 0) {
return prev;
}
const remaining = new Set(prev);
linkedIds.forEach((id) => remaining.delete(id));
return failedIds.length > 0 ? remaining : new Set();
});
if (failedIds.length > 0) {
setError(
failedIds.length === idsToLink.length
? "Failed to link stations"
: "Some stations failed to link"
);
}
setIsLinkingBulk(false);
};
const toggleDetachSelection = (itemId: number) => {
const updated = new Set(selectedToDetach);
if (updated.has(itemId)) {
updated.delete(itemId);
} else {
updated.add(itemId);
}
setSelectedToDetach(updated);
};
const handleToggleAllDetach = (checked: boolean) => {
if (!checked) {
setSelectedToDetach(new Set());
return;
}
setSelectedToDetach(new Set(linkedItems.map((item) => item.id)));
};
const handleBulkDetach = async () => {
const idsToDetach = Array.from(selectedToDetach);
if (idsToDetach.length === 0) return;
setError(null);
setIsBulkDetaching(true);
setDetachingIds((prev) => {
const next = new Set(prev);
idsToDetach.forEach((id) => next.add(id));
return next;
});
const detachedIds: number[] = [];
const failedIds: number[] = [];
for (const itemId of idsToDetach) {
try {
await authInstance.delete(`/${parentResource}/${parentId}/${childResource}`, {
data: { [`${childResource}_id`]: itemId },
});
detachedIds.push(itemId);
} catch (error) {
console.error("Error deleting station:", error);
failedIds.push(itemId);
}
}
if (detachedIds.length > 0) {
setLinkedItems((prev) =>
prev.filter((item) => !detachedIds.includes(item.id))
);
setSelectedToDetach((prev) => {
const remaining = new Set(prev);
detachedIds.forEach((id) => remaining.delete(id));
return failedIds.length > 0 ? remaining : new Set();
});
onUpdate?.();
}
if (failedIds.length > 0) {
setError(
failedIds.length === idsToDetach.length
? "Failed to delete stations"
: "Some stations failed to delete"
);
}
setDetachingIds((prev) => {
const next = new Set(prev);
idsToDetach.forEach((id) => next.delete(id));
return next;
});
setIsBulkDetaching(false);
};
const allSelectedForDetach =
linkedItems.length > 0 &&
linkedItems.every((item) => selectedToDetach.has(item.id));
const isIndeterminateDetach =
selectedToDetach.size > 0 && !allSelectedForDetach;
useEffect(() => {
if (parentId) {
setIsLoading(true);
setError(null);
authInstance
.get(`/${parentResource}/${parentId}/${childResource}`)
.then((response) => {
setLinkedItems(response?.data || []);
})
.catch((error) => {
console.error("Error fetching linked stations:", error);
setError("Failed to load linked stations");
setLinkedItems([]);
})
.finally(() => {
setIsLoading(false);
});
}
}, [parentId, language, refresh]);
useEffect(() => {
if (type === "edit") {
setError(null);
authInstance
.get(`/${childResource}`)
.then((response) => {
setAllItems(response?.data || []);
})
.catch((error) => {
console.error("Error fetching all stations:", error);
setError("Failed to load available stations");
setAllItems([]);
});
}
}, [type]);
return (
<>
{linkedItems?.length > 0 && (
<TableContainer component={Paper} sx={{ width: "100%" }}>
<Table sx={{ width: "100%" }}>
<TableHead>
<TableRow>
{type === "edit" && (
<TableCell width="50px">
<Checkbox
size="small"
checked={allSelectedForDetach}
indeterminate={isIndeterminateDetach}
onChange={(e) => handleToggleAllDetach(e.target.checked)}
/>
</TableCell>
)}
<TableCell key="id" width="60px">
</TableCell>
{fields.map((field) => (
<TableCell key={String(field.data)}>{field.label}</TableCell>
))}
{type === "edit" && (
<TableCell width="120px">Действие</TableCell>
)}
</TableRow>
</TableHead>
<TableBody>
{linkedItems.map((item, index) => (
<TableRow key={item.id} hover>
{type === "edit" && (
<TableCell>
<Checkbox
size="small"
checked={selectedToDetach.has(item.id)}
onChange={() => toggleDetachSelection(item.id)}
/>
</TableCell>
)}
<TableCell>{index + 1}</TableCell>
{fields.map((field, idx) => (
<TableCell key={String(field.data) + String(idx)}>
{field.render
? field.render(item[field.data])
: item[field.data]}
</TableCell>
))}
{type === "edit" && (
<TableCell>
<AnimatedCircleButton
variant="outlined"
color="error"
size="small"
onClick={(e) => {
e.stopPropagation();
deleteItem(item.id);
}}
disabled={detachingIds.has(item.id)}
loading={detachingIds.has(item.id)}
>
Отвязать
</AnimatedCircleButton>
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
{type === "edit" && linkedItems.length > 0 && (
<Stack direction="row" gap={2} mt={2}>
<AnimatedCircleButton
variant="outlined"
color="error"
onClick={handleBulkDetach}
disabled={selectedToDetach.size === 0 || isBulkDetaching}
loading={isBulkDetaching}
>
Отвязать выбранные ({selectedToDetach.size})
</AnimatedCircleButton>
</Stack>
)}
{linkedItems.length === 0 && !isLoading && (
<Typography color="textSecondary" textAlign="center" py={2}>
Остановки не найдены
</Typography>
)}
{type === "edit" && !disableCreation && (
<Stack gap={2} mt={2}>
<Typography variant="subtitle1">Добавить остановки</Typography>
<Tabs
value={activeTab}
onChange={(_, value) => setActiveTab(value)}
variant="fullWidth"
>
<Tab label="По одной" />
<Tab label="Массово" />
</Tabs>
<Box sx={{ mt: 1 }}>
{activeTab === 0 && (
<Stack gap={2}>
<Autocomplete
fullWidth
value={
availableItems?.find((item) => item.id === selectedItemId) ||
null
}
onChange={(_, newValue) =>
setSelectedItemId(newValue?.id || null)
}
options={availableItems}
getOptionLabel={(item) => String(item.name)}
renderInput={(params) => (
<TextField {...params} label="Выберите остановку" fullWidth />
)}
isOptionEqualToValue={(option, value) =>
option.id === value?.id
}
filterOptions={(options, { inputValue }) => {
const searchWords = inputValue
.toLowerCase()
.split(" ")
.filter(Boolean);
return options.filter((option) => {
const optionWords = String(option.name)
.toLowerCase()
.split(" ");
return searchWords.every((searchWord) =>
optionWords.some((word) => word.startsWith(searchWord))
);
});
}}
renderOption={(props, option) => (
<li {...props} key={option.id}>
{String(option.name)}
</li>
)}
/>
<AnimatedCircleButton
variant="contained"
onClick={linkItem}
disabled={!selectedItemId || isLinkingSingle}
loading={isLinkingSingle}
sx={{ alignSelf: "flex-start" }}
>
Добавить
</AnimatedCircleButton>
</Stack>
)}
{activeTab === 1 && (
<Stack gap={2}>
<TextField
fullWidth
label="Поиск остановок"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Введите название остановки..."
size="small"
/>
<Paper sx={{ maxHeight: 300, overflow: "auto", p: 2 }}>
<Stack gap={1}>
{filteredAvailableItems.map((item) => (
<FormControlLabel
key={item.id}
control={
<Checkbox
checked={selectedItems.has(item.id)}
onChange={() => handleCheckboxChange(item.id)}
size="small"
/>
}
label={String(item.name)}
sx={{
margin: 0,
"& .MuiFormControlLabel-label": {
fontSize: "0.9rem",
},
}}
/>
))}
{filteredAvailableItems.length === 0 && (
<Typography
color="textSecondary"
textAlign="center"
py={1}
>
{searchQuery.trim()
? "Остановки не найдены"
: "Нет доступных остановок"}
</Typography>
)}
</Stack>
</Paper>
<AnimatedCircleButton
variant="contained"
onClick={handleBulkLink}
disabled={selectedItems.size === 0 || isLinkingBulk}
loading={isLinkingBulk}
sx={{ alignSelf: "flex-start" }}
>
Добавить выбранные ({selectedItems.size})
</AnimatedCircleButton>
</Stack>
)}
</Box>
</Stack>
)}
{isLoading && (
<Typography color="textSecondary" textAlign="center" py={2}>
Загрузка...
</Typography>
)}
{error && (
<Typography color="error" textAlign="center" py={2}>
{error}
</Typography>
)}
</>
);
};
export const LinkedStationsContents = observer(
LinkedStationsContentsInner
) as typeof LinkedStationsContentsInner;

View File

@@ -1 +1,2 @@
export * from "./SightListPage";
export { LinkedStations } from "./LinkedStations";

View File

@@ -2,7 +2,6 @@ import { useState, useEffect } from "react";
import {
Stack,
Typography,
Button,
Accordion,
AccordionSummary,
AccordionDetails,
@@ -16,11 +15,21 @@ import {
TableRow,
Paper,
TableBody,
Checkbox,
FormControlLabel,
Tabs,
Tab,
Box,
} from "@mui/material";
import { observer } from "mobx-react-lite";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import { authInstance, languageStore, selectedCityStore } from "@shared";
import {
AnimatedCircleButton,
authInstance,
languageStore,
selectedCityStore,
} from "@shared";
type Field<T> = {
label: string;
@@ -93,6 +102,16 @@ const LinkedSightsContentsInner = <
const [selectedItemId, setSelectedItemId] = useState<number | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState(0);
const [selectedItems, setSelectedItems] = useState<Set<number>>(new Set());
const [searchQuery, setSearchQuery] = useState("");
const [selectedToDetach, setSelectedToDetach] = useState<Set<number>>(
new Set()
);
const [isLinkingSingle, setIsLinkingSingle] = useState(false);
const [isLinkingBulk, setIsLinkingBulk] = useState(false);
const [isBulkDetaching, setIsBulkDetaching] = useState(false);
const [detachingIds, setDetachingIds] = useState<Set<number>>(new Set());
useEffect(() => {}, [error]);
@@ -111,6 +130,11 @@ const LinkedSightsContentsInner = <
})
.sort((a, b) => a.name.localeCompare(b.name));
const filteredAvailableItems = availableItems.filter((item) => {
if (!searchQuery.trim()) return true;
return String(item.name).toLowerCase().includes(searchQuery.toLowerCase());
});
useEffect(() => {
if (updatedLinkedItems) {
setLinkedItems(updatedLinkedItems);
@@ -121,6 +145,18 @@ const LinkedSightsContentsInner = <
setItemsParent?.(linkedItems);
}, [linkedItems, setItemsParent]);
useEffect(() => {
setSelectedToDetach((prev) => {
const updated = new Set<number>();
linkedItems.forEach((item) => {
if (prev.has(item.id)) {
updated.add(item.id);
}
});
return updated;
});
}, [linkedItems]);
const linkItem = () => {
if (selectedItemId !== null) {
setError(null);
@@ -128,6 +164,7 @@ const LinkedSightsContentsInner = <
sight_id: selectedItemId,
};
setIsLinkingSingle(true);
authInstance
.post(`/${parentResource}/${parentId}/${childResource}`, requestData)
.then(() => {
@@ -141,12 +178,20 @@ const LinkedSightsContentsInner = <
.catch((error) => {
console.error("Error linking sight:", error);
setError("Failed to link sight");
})
.finally(() => {
setIsLinkingSingle(false);
});
}
};
const deleteItem = (itemId: number) => {
setError(null);
setDetachingIds((prev) => {
const next = new Set(prev);
next.add(itemId);
return next;
});
authInstance
.delete(`/${parentResource}/${parentId}/${childResource}`, {
data: { [`${childResource}_id`]: itemId },
@@ -158,9 +203,162 @@ const LinkedSightsContentsInner = <
.catch((error) => {
console.error("Error deleting sight:", error);
setError("Failed to delete sight");
})
.finally(() => {
setDetachingIds((prev) => {
const next = new Set(prev);
next.delete(itemId);
return next;
});
});
};
const handleCheckboxChange = (itemId: number) => {
const updated = new Set(selectedItems);
if (updated.has(itemId)) {
updated.delete(itemId);
} else {
updated.add(itemId);
}
setSelectedItems(updated);
};
const handleBulkLink = async () => {
if (selectedItems.size === 0) return;
setError(null);
setIsLinkingBulk(true);
const idsToLink = Array.from(selectedItems);
const linkedIds: number[] = [];
const failedIds: number[] = [];
for (const id of idsToLink) {
try {
await authInstance.post(
`/${parentResource}/${parentId}/${childResource}`,
{
sight_id: id,
}
);
linkedIds.push(id);
} catch (error) {
console.error("Error linking sight:", error);
failedIds.push(id);
}
}
if (linkedIds.length > 0) {
const newItems = allItems.filter((item) => linkedIds.includes(item.id));
setLinkedItems((prev) => {
const existingIds = new Set(prev.map((item) => item.id));
const additions = newItems.filter((item) => !existingIds.has(item.id));
return [...prev, ...additions];
});
onUpdate?.();
}
setSelectedItems((prev) => {
if (linkedIds.length === 0) {
return prev;
}
const remaining = new Set(prev);
linkedIds.forEach((id) => remaining.delete(id));
return failedIds.length > 0 ? remaining : new Set();
});
if (failedIds.length > 0) {
setError(
failedIds.length === idsToLink.length
? "Failed to link sights"
: "Some sights failed to link"
);
}
setIsLinkingBulk(false);
};
const toggleDetachSelection = (itemId: number) => {
const updated = new Set(selectedToDetach);
if (updated.has(itemId)) {
updated.delete(itemId);
} else {
updated.add(itemId);
}
setSelectedToDetach(updated);
};
const handleToggleAllDetach = (checked: boolean) => {
if (!checked) {
setSelectedToDetach(new Set());
return;
}
setSelectedToDetach(new Set(linkedItems.map((item) => item.id)));
};
const handleBulkDetach = async () => {
const idsToDetach = Array.from(selectedToDetach);
if (idsToDetach.length === 0) return;
setError(null);
setIsBulkDetaching(true);
setDetachingIds((prev) => {
const next = new Set(prev);
idsToDetach.forEach((id) => next.add(id));
return next;
});
const detachedIds: number[] = [];
const failedIds: number[] = [];
for (const itemId of idsToDetach) {
try {
await authInstance.delete(
`/${parentResource}/${parentId}/${childResource}`,
{
data: { [`${childResource}_id`]: itemId },
}
);
detachedIds.push(itemId);
} catch (error) {
console.error("Error deleting sight:", error);
failedIds.push(itemId);
}
}
if (detachedIds.length > 0) {
setLinkedItems((prev) =>
prev.filter((item) => !detachedIds.includes(item.id))
);
setSelectedToDetach((prev) => {
const remaining = new Set(prev);
detachedIds.forEach((id) => remaining.delete(id));
return failedIds.length > 0 ? remaining : new Set();
});
onUpdate?.();
}
if (failedIds.length > 0) {
setError(
failedIds.length === idsToDetach.length
? "Failed to delete sights"
: "Some sights failed to delete"
);
}
setDetachingIds((prev) => {
const next = new Set(prev);
idsToDetach.forEach((id) => next.delete(id));
return next;
});
setIsBulkDetaching(false);
};
const allSelectedForDetach =
linkedItems.length > 0 &&
linkedItems.every((item) => selectedToDetach.has(item.id));
const isIndeterminateDetach =
selectedToDetach.size > 0 && !allSelectedForDetach;
useEffect(() => {
if (parentId) {
setIsLoading(true);
@@ -204,6 +402,16 @@ const LinkedSightsContentsInner = <
<Table sx={{ width: "100%" }}>
<TableHead>
<TableRow>
{type === "edit" && (
<TableCell width="50px">
<Checkbox
size="small"
checked={allSelectedForDetach}
indeterminate={isIndeterminateDetach}
onChange={(e) => handleToggleAllDetach(e.target.checked)}
/>
</TableCell>
)}
<TableCell key="id" width="60px">
</TableCell>
@@ -219,6 +427,15 @@ const LinkedSightsContentsInner = <
<TableBody>
{linkedItems.map((item, index) => (
<TableRow key={item.id} hover>
{type === "edit" && (
<TableCell>
<Checkbox
size="small"
checked={selectedToDetach.has(item.id)}
onChange={() => toggleDetachSelection(item.id)}
/>
</TableCell>
)}
<TableCell>{index + 1}</TableCell>
{fields.map((field, idx) => (
<TableCell key={String(field.data) + String(idx)}>
@@ -229,7 +446,7 @@ const LinkedSightsContentsInner = <
))}
{type === "edit" && (
<TableCell>
<Button
<AnimatedCircleButton
variant="outlined"
color="error"
size="small"
@@ -237,9 +454,11 @@ const LinkedSightsContentsInner = <
e.stopPropagation();
deleteItem(item.id);
}}
disabled={detachingIds.has(item.id)}
loading={detachingIds.has(item.id)}
>
Отвязать
</Button>
</AnimatedCircleButton>
</TableCell>
)}
</TableRow>
@@ -249,6 +468,20 @@ const LinkedSightsContentsInner = <
</TableContainer>
)}
{type === "edit" && linkedItems.length > 0 && (
<Stack direction="row" gap={2} mt={2}>
<AnimatedCircleButton
variant="outlined"
color="error"
onClick={handleBulkDetach}
disabled={selectedToDetach.size === 0 || isBulkDetaching}
loading={isBulkDetaching}
>
Отвязать выбранные ({selectedToDetach.size})
</AnimatedCircleButton>
</Stack>
)}
{linkedItems.length === 0 && !isLoading && (
<Typography color="textSecondary" textAlign="center" py={2}>
Достопримечательности не найдены
@@ -258,53 +491,133 @@ const LinkedSightsContentsInner = <
{type === "edit" && !disableCreation && (
<Stack gap={2} mt={2}>
<Typography variant="subtitle1">
Добавить достопримечательность
Добавить достопримечательности
</Typography>
<Autocomplete
fullWidth
value={
availableItems?.find((item) => item.id === selectedItemId) || null
}
onChange={(_, newValue) => setSelectedItemId(newValue?.id || null)}
options={availableItems}
getOptionLabel={(item) => String(item.name)}
renderInput={(params) => (
<TextField
{...params}
label="Выберите достопримечательность"
fullWidth
/>
)}
isOptionEqualToValue={(option, value) => option.id === value?.id}
filterOptions={(options, { inputValue }) => {
const searchWords = inputValue
.toLowerCase()
.split(" ")
.filter(Boolean);
return options.filter((option) => {
const optionWords = String(option.name)
.toLowerCase()
.split(" ");
return searchWords.every((searchWord) =>
optionWords.some((word) => word.startsWith(searchWord))
);
});
}}
renderOption={(props, option) => (
<li {...props} key={option.id}>
{String(option.name)}
</li>
)}
/>
<Button
variant="contained"
onClick={linkItem}
disabled={!selectedItemId}
sx={{ alignSelf: "flex-start" }}
<Tabs
value={activeTab}
onChange={(_, value) => setActiveTab(value)}
variant="fullWidth"
>
Добавить
</Button>
<Tab label="По одной" />
<Tab label="Массово" />
</Tabs>
<Box sx={{ mt: 1 }}>
{activeTab === 0 && (
<Stack gap={2}>
<Autocomplete
fullWidth
value={
availableItems?.find(
(item) => item.id === selectedItemId
) || null
}
onChange={(_, newValue) =>
setSelectedItemId(newValue?.id || null)
}
options={availableItems}
getOptionLabel={(item) => String(item.name)}
renderInput={(params) => (
<TextField
{...params}
label="Выберите достопримечательность"
fullWidth
/>
)}
isOptionEqualToValue={(option, value) =>
option.id === value?.id
}
filterOptions={(options, { inputValue }) => {
const searchWords = inputValue
.toLowerCase()
.split(" ")
.filter(Boolean);
return options.filter((option) => {
const optionWords = String(option.name)
.toLowerCase()
.split(" ");
return searchWords.every((searchWord) =>
optionWords.some((word) => word.startsWith(searchWord))
);
});
}}
renderOption={(props, option) => (
<li {...props} key={option.id}>
{String(option.name)}
</li>
)}
/>
<AnimatedCircleButton
variant="contained"
onClick={linkItem}
disabled={!selectedItemId || isLinkingSingle}
loading={isLinkingSingle}
sx={{ alignSelf: "flex-start" }}
>
Добавить
</AnimatedCircleButton>
</Stack>
)}
{activeTab === 1 && (
<Stack gap={2}>
<TextField
fullWidth
label="Поиск достопримечательностей"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Введите название..."
size="small"
/>
<Paper sx={{ maxHeight: 300, overflow: "auto", p: 2 }}>
<Stack gap={1}>
{filteredAvailableItems.map((item) => (
<FormControlLabel
key={item.id}
control={
<Checkbox
checked={selectedItems.has(item.id)}
onChange={() => handleCheckboxChange(item.id)}
size="small"
/>
}
label={String(item.name)}
sx={{
margin: 0,
"& .MuiFormControlLabel-label": {
fontSize: "0.9rem",
},
}}
/>
))}
{filteredAvailableItems.length === 0 && (
<Typography
color="textSecondary"
textAlign="center"
py={1}
>
{searchQuery.trim()
? "Достопримечательности не найдены"
: "Нет доступных достопримечательностей"}
</Typography>
)}
</Stack>
</Paper>
<AnimatedCircleButton
variant="contained"
onClick={handleBulkLink}
disabled={selectedItems.size === 0 || isLinkingBulk}
loading={isLinkingBulk}
sx={{ alignSelf: "flex-start" }}
>
Добавить выбранные ({selectedItems.size})
</AnimatedCircleButton>
</Stack>
)}
</Box>
</Stack>
)}

View File

@@ -35,7 +35,7 @@ export const StationCreatePage = observer(() => {
const { cities, getCities } = cityStore;
const { selectedCityId, selectedCity } = useSelectedCity();
const [coordinates, setCoordinates] = useState<string>("");
// НОВОЕ СОСТОЯНИЕ ДЛЯ ПРЕДУПРЕЖДАЮЩЕГО ОКНА
const [isSaveWarningOpen, setIsSaveWarningOpen] = useState(false);
useEffect(() => {
@@ -49,7 +49,6 @@ export const StationCreatePage = observer(() => {
}
}, [createStationData.common.latitude, createStationData.common.longitude]);
// НОВАЯ ФУНКЦИЯ: Фактическое создание (вызывается после подтверждения)
const executeCreate = async () => {
try {
setIsLoading(true);
@@ -64,11 +63,13 @@ export const StationCreatePage = observer(() => {
}
};
// ОБНОВЛЕННАЯ ФУНКЦИЯ: Проверка и вызов окна или создания
const handleCreate = async () => {
const isCityMissing = !createStationData.common.city_id;
// Проверяем названия на всех языках
const isNameMissing = !createStationData.ru.name || !createStationData.en.name || !createStationData.zh.name;
const isNameMissing =
!createStationData.ru.name ||
!createStationData.en.name ||
!createStationData.zh.name;
if (isCityMissing || isNameMissing) {
setIsSaveWarningOpen(true);
@@ -78,13 +79,11 @@ export const StationCreatePage = observer(() => {
await executeCreate();
};
// Обработчик "Да" в предупреждающем окне
const handleConfirmCreate = async () => {
setIsSaveWarningOpen(false);
await executeCreate();
};
// Обработчик "Нет" в предупреждающем окне
const handleCancelCreate = () => {
setIsSaveWarningOpen(false);
};
@@ -99,7 +98,6 @@ export const StationCreatePage = observer(() => {
fetchCities();
}, []);
// Автоматически устанавливаем выбранный город при загрузке страницы
useEffect(() => {
if (selectedCityId && selectedCity && !createStationData.common.city_id) {
setCreateCommonData({
@@ -237,7 +235,7 @@ export const StationCreatePage = observer(() => {
className="w-min flex gap-2 items-center"
startIcon={<Save size={20} />}
onClick={handleCreate}
disabled={isLoading} // Убрал проверку name/city_id отсюда, чтобы ее обрабатывал handleCreate
disabled={isLoading}
>
{isLoading ? (
<Loader2 size={20} className="animate-spin" />

View File

@@ -32,7 +32,7 @@ export const StationEditPage = observer(() => {
} = stationsStore;
const { cities, getCities } = cityStore;
const [coordinates, setCoordinates] = useState<string>("");
// НОВОЕ СОСТОЯНИЕ ДЛЯ ПРЕДУПРЕЖДАЮЩЕГО ОКНА
const [isSaveWarningOpen, setIsSaveWarningOpen] = useState(false);
useEffect(() => {
@@ -50,7 +50,6 @@ export const StationEditPage = observer(() => {
}
}, [editStationData.common.latitude, editStationData.common.longitude]);
// НОВАЯ ФУНКЦИЯ: Фактическое редактирование (вызывается после подтверждения)
const executeEdit = async () => {
try {
setIsLoading(true);
@@ -64,10 +63,9 @@ export const StationEditPage = observer(() => {
}
};
// ОБНОВЛЕННАЯ ФУНКЦИЯ: Проверка и вызов окна или редактирования
const handleEdit = async () => {
const isCityMissing = !editStationData.common.city_id;
// Проверяем названия на всех языках
const isNameMissing =
!editStationData.ru.name ||
!editStationData.en.name ||
@@ -81,13 +79,11 @@ export const StationEditPage = observer(() => {
await executeEdit();
};
// Обработчик "Да" в предупреждающем окне
const handleConfirmEdit = async () => {
setIsSaveWarningOpen(false);
await executeEdit();
};
// Обработчик "Нет" в предупреждающем окне
const handleCancelEdit = () => {
setIsSaveWarningOpen(false);
};
@@ -243,7 +239,7 @@ export const StationEditPage = observer(() => {
className="w-min flex gap-2 items-center"
startIcon={<Save size={20} />}
onClick={handleEdit}
disabled={isLoading} // Убрал проверку name/city_id отсюда, чтобы ее обрабатывал handleEdit
disabled={isLoading}
>
{isLoading ? (
<Loader2 size={20} className="animate-spin" />

View File

@@ -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>
);
};

View 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

View File

@@ -8,15 +8,13 @@ import {
Earth,
Landmark,
GitBranch,
// Car,
Table,
Split,
// Newspaper,
PersonStanding,
Cpu,
// BookImage,
} from "lucide-react";
import { CarrierSvg } from "./CarrierSvg";
import carrierIcon from "./carrier.svg";
export const DRAWER_WIDTH = 300;
@@ -56,12 +54,6 @@ export const NAVIGATION_ITEMS: {
path: "/devices",
for_admin: true,
},
// {
// id: "vehicles",
// label: "Транспорт",
// icon: Car,
// path: "/vehicle",
// },
{
id: "users",
label: "Пользователи",
@@ -74,18 +66,6 @@ export const NAVIGATION_ITEMS: {
label: "Справочник",
icon: Table,
nestedItems: [
// {
// id: "media",
// label: "Медиа",
// icon: BookImage,
// path: "/media",
// },
// {
// id: "articles",
// label: "Статьи",
// icon: Newspaper,
// path: "/article",
// },
{
id: "attractions",
label: "Достопримечательности",
@@ -123,7 +103,7 @@ export const NAVIGATION_ITEMS: {
id: "carriers",
label: "Перевозчики",
// @ts-ignore
icon: CarrierSvg,
icon: () => <img src={carrierIcon} alt="Перевозчики" />,
path: "/carrier",
for_admin: true,
},

View File

@@ -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: "Видео",
@@ -22,751 +40,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");

View File

@@ -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();
}

View File

@@ -2,34 +2,17 @@ export * from "./mui/theme";
export * from "./DecodeJWT";
export * from "./gltfCacheManager";
/**
* Генерирует название медиа по умолчанию в разных форматах
*
* Примеры использования:
* - Для достопримечательности с названием: "Михайловский замок_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 +25,11 @@ export const generateDefaultMediaName = (
const mediaTypeLabel = mediaTypeLabels[mediaType] || "Медиа";
if (objectName && objectName.trim() !== "") {
// Если есть название объекта: "Название объектаазвание файла_тип медиа"
return `${objectName}_${fileNameWithoutExtension}_${mediaTypeLabel}`;
} else {
// Если нет названия объекта: "Названиеазвание файла_тип медиа"
return `Название_${fileNameWithoutExtension}_${mediaTypeLabel}`;
}
}
// Fallback
return `${objectName || "Название"}_${fileNameWithoutExtension}_Медиа`;
};

File diff suppressed because it is too large Load Diff

View File

@@ -54,7 +54,7 @@ interface UploadMediaDialogProps {
| "station";
isArticle?: boolean;
articleName?: string;
initialFile?: File; // <--- добавлено
initialFile?: File;
}
export const UploadMediaDialog = observer(
@@ -68,7 +68,7 @@ export const UploadMediaDialog = observer(
isArticle,
articleName,
initialFile, // <--- добавлено
initialFile,
}: UploadMediaDialogProps) => {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -87,7 +87,6 @@ export const UploadMediaDialog = observer(
useEffect(() => {
if (initialFile) {
// Очищаем предыдущий blob URL если он существует
if (
previousMediaUrlRef.current &&
previousMediaUrlRef.current.startsWith("blob:")
@@ -106,7 +105,6 @@ export const UploadMediaDialog = observer(
}
}, [initialFile]);
// Очистка blob URL при размонтировании компонента
useEffect(() => {
return () => {
if (
@@ -116,13 +114,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 +132,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 +151,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 +161,9 @@ export const UploadMediaDialog = observer(
false
);
} else {
// Для медиа без названия объекта
const currentMediaType = hardcodeType
? MEDIA_TYPE_VALUES[hardcodeType]
: 1; // По умолчанию фото
: 1;
defaultName = generateDefaultMediaName(
"",
fileToUpload.name,
@@ -185,13 +177,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 +189,6 @@ export const UploadMediaDialog = observer(
true
);
} else if (contextObjectName && contextObjectName.trim() !== "") {
// Для обычных медиа с названием объекта
const currentMediaType = hardcodeType
? MEDIA_TYPE_VALUES[hardcodeType]
: mediaType;
@@ -210,7 +199,6 @@ export const UploadMediaDialog = observer(
false
);
} else {
// Для медиа без названия объекта
const currentMediaType = hardcodeType
? MEDIA_TYPE_VALUES[hardcodeType]
: mediaType;
@@ -235,7 +223,6 @@ export const UploadMediaDialog = observer(
useEffect(() => {
if (mediaFile) {
// Очищаем предыдущий blob URL и кеш GLTF если он существует
if (
previousMediaUrlRef.current &&
previousMediaUrlRef.current.startsWith("blob:")
@@ -245,22 +232,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;
@@ -285,10 +260,10 @@ export const UploadMediaDialog = observer(
}
}
setSuccess(true);
// Закрываем модальное окно после успешного сохранения
setTimeout(() => {
handleClose();
}, 1000); // Небольшая задержка, чтобы пользователь увидел сообщение об успехе
}, 1000);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to save media");
} finally {
@@ -297,7 +272,6 @@ export const UploadMediaDialog = observer(
};
const handleClose = () => {
// Очищаем blob URL и кеш GLTF при закрытии диалога
if (
previousMediaUrlRef.current &&
previousMediaUrlRef.current.startsWith("blob:")
@@ -310,7 +284,7 @@ export const UploadMediaDialog = observer(
setMediaUrl(null);
setMediaFile(null);
setIsPreviewLoaded(false);
previousMediaUrlRef.current = null; // Очищаем ref
previousMediaUrlRef.current = null;
onClose();
};

View File

@@ -2,3 +2,4 @@ export * from "./SelectArticleDialog";
export * from "./SelectMediaDialog";
export * from "./PreviewMediaDialog";
export * from "./UploadMediaDialog";
export * from "./ArticleSelectOrCreateDialog";

View File

@@ -171,7 +171,6 @@ 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)) {
const patchPayload = {
// @ts-ignore

View File

@@ -1,4 +1,3 @@
// @shared/stores/createSightStore.ts
import {
articlesStore,
Language,
@@ -27,7 +26,6 @@ type SightLanguageInfo = {
};
type SightCommonInfo = {
// id: number; // ID is 0 until created
city_id: number;
city: string;
latitude: number;
@@ -35,13 +33,11 @@ type SightCommonInfo = {
thumbnail: 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;
};
// 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;
};
@@ -78,7 +74,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 +89,7 @@ class CreateSightStore {
makeAutoObservable(this);
}
// --- Right Article Management ---
createNewRightArticle = async () => {
// Create article in DB for all languages
const articleRuData = {
heading: "Новый заголовок (RU)",
body: "Новый текст (RU)",
@@ -125,7 +119,7 @@ class CreateSightStore {
},
},
});
const { id } = articleRes.data; // New article's ID
const { id } = articleRes.data;
runInAction(() => {
const newArticleEntry = { id, media: [] };
@@ -133,7 +127,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 +163,7 @@ class CreateSightStore {
});
});
return articleId; // Return the linked article ID
return articleId;
} catch (error) {
console.error("Error linking existing right article:", error);
throw error;
@@ -188,9 +182,7 @@ 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
@@ -202,16 +194,12 @@ class CreateSightStore {
(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
);
@@ -228,12 +216,11 @@ 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) => {
@@ -242,7 +229,7 @@ class CreateSightStore {
);
if (article) {
if (!article.media) article.media = [];
article.media.unshift(media); // Add to the beginning
article.media.unshift(media);
}
});
});
@@ -273,7 +260,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,7 +309,7 @@ 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
@@ -340,63 +326,69 @@ 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: "",
});
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 +406,6 @@ class CreateSightStore {
};
};
// --- Sight Preview Media ---
linkPreviewMedia = (mediaId: string) => {
this.sight.preview_media = mediaId;
};
@@ -423,32 +414,27 @@ 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
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,
@@ -466,7 +452,6 @@ 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 }
@@ -480,10 +465,7 @@ class CreateSightStore {
{ 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,14 +475,12 @@ 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
);
// 3. Create Sight object in DB
const sightPayload = {
city_id: this.sight.city_id,
city: this.sight.city,
@@ -520,9 +500,8 @@ class CreateSightStore {
"/sight",
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
);
@@ -543,20 +522,17 @@ 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;
return newSightId;
};
// --- Media Upload (Generic, used by dialogs) ---
uploadMedia = async (
filename: string,
type: number,
@@ -575,12 +551,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 +564,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."
);
// 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 {
@@ -655,7 +628,7 @@ class CreateSightStore {
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;
};

View File

@@ -1,4 +1,3 @@
// @shared/stores/editSightStore.ts
import {
articlesStore,
authInstance,
@@ -96,13 +95,11 @@ class EditSightStore {
}
runInAction(() => {
// Обновляем языковую часть
this.sight[language] = {
...this.sight[language],
...data,
};
// Только при первом запросе обновляем общую часть
if (!this.hasLoadedCommon) {
this.sight.common = {
...this.sight.common,
@@ -123,7 +120,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 +128,6 @@ class EditSightStore {
mediaMap.set(article.id, responseMedia.data);
}
// Function to add media to articles
const addMediaToArticles = (articles: any[]) => {
return articles.map((article) => ({
...article,
@@ -327,28 +322,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 +373,36 @@ 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: "",
});
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 = "";
@@ -569,7 +562,7 @@ class EditSightStore {
});
});
return article_id; // Return the linked article ID
return article_id;
};
deleteRightArticleMedia = async (article_id: number, media_id: string) => {
@@ -675,7 +668,7 @@ class EditSightStore {
});
});
return id; // Return the ID of the newly created article
return id;
};
createLinkWithRightArticle = async (
@@ -750,7 +743,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;
};

View File

@@ -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);
}
};
}

View File

@@ -39,12 +39,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 +63,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 };
}

View File

@@ -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();
}

View File

@@ -2,6 +2,7 @@ import { makeAutoObservable, runInAction } from "mobx";
import { authInstance } from "@shared";
export type Route = {
route_name: string;
carrier: string;
carrier_id: number;
center_latitude: number;
@@ -97,6 +98,7 @@ class RouteStore {
};
editRouteData = {
route_name: "",
carrier: "",
carrier_id: 0,
center_latitude: "",
@@ -110,7 +112,7 @@ class RouteStore {
route_sys_number: "",
scale_max: 0,
scale_min: 0,
video_preview: "",
video_preview: "" as string | undefined,
};
setEditRouteData = (data: any) => {
@@ -118,6 +120,9 @@ class RouteStore {
};
editRoute = async (id: number) => {
if (!this.editRouteData.video_preview) {
delete this.editRouteData.video_preview;
}
const response = await authInstance.patch(`/route/${id}`, {
...this.editRouteData,
center_latitude: parseFloat(this.editRouteData.center_latitude),

View File

@@ -58,41 +58,6 @@ 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 }

View File

@@ -1,8 +1,6 @@
import { authInstance } from "@shared";
import { makeAutoObservable, runInAction } from "mobx";
// Импорт функции сброса кешей карты
// import { clearMapCaches } from "../../pages/MapPage";
import {
articlesStore,
cityStore,
@@ -35,9 +33,7 @@ class SnapshotStore {
makeAutoObservable(this);
}
// Функция для сброса всех кешей в приложении
private clearAllCaches = () => {
// Сброс кешей статей
articlesStore.articleList = {
ru: { data: [], loaded: false },
en: { data: [], loaded: false },
@@ -47,7 +43,6 @@ class SnapshotStore {
articlesStore.articleData = null;
articlesStore.articleMedia = null;
// Сброс кешей городов
cityStore.cities = {
ru: { data: [], loaded: false },
en: { data: [], loaded: false },
@@ -56,21 +51,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,24 +70,18 @@ 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,
@@ -173,26 +159,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 +181,6 @@ class SnapshotStore {
console.warn("Не удалось сбросить кеши карты:", error);
}
// Сброс localStorage кешей (кроме токена авторизации)
const token = localStorage.getItem("token");
const rememberedEmail = localStorage.getItem("rememberedEmail");
const rememberedPassword = localStorage.getItem("rememberedPassword");
@@ -208,14 +188,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 +203,6 @@ class SnapshotStore {
localStorage.removeItem(activeSectionKey);
}
// Попытка очистить кеш браузера (если поддерживается)
if ("caches" in window) {
try {
caches.keys().then((cacheNames) => {
@@ -240,7 +217,6 @@ class SnapshotStore {
}
}
// Попытка очистить IndexedDB (если поддерживается)
if ("indexedDB" in window) {
try {
indexedDB.databases().then((databases) => {
@@ -284,10 +260,8 @@ class SnapshotStore {
};
restoreSnapshot = async (id: string) => {
// Сначала сбрасываем все кеши
this.clearAllCaches();
// Затем восстанавливаем снапшот
await authInstance.post(`/snapshots/${id}/restore`);
};

View File

@@ -7,7 +7,7 @@ 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 = {
@@ -92,7 +92,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 }>
@@ -264,7 +263,6 @@ class StationsStore {
};
};
// Sets language-specific station data
setLanguageEditStationData = (
language: Language,
data: Partial<StationLanguageData>
@@ -295,7 +293,7 @@ class StationsStore {
`/station/${id}`,
{
name: name || "",
system_name: name || "", // system_name is often derived from name
system_name: name || "",
description: description || "",
address: address || "",
...commonDataPayload,
@@ -303,7 +301,6 @@ class StationsStore {
);
runInAction(() => {
// Update the cached preview data and station lists after successful patch
if (this.stationPreview[id]) {
this.stationPreview[id][language] = {
loaded: true,
@@ -343,11 +340,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(
@@ -421,12 +418,11 @@ 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
system_name: name || "",
description: description || "",
address: address || "",
...commonDataPayload,
@@ -438,7 +434,6 @@ class StationsStore {
const stationId = response.data.id;
// Then update for other languages
for (const lang of ["ru", "en", "zh"].filter(
(lang) => lang !== language
) as Language[]) {
@@ -448,7 +443,7 @@ class StationsStore {
`/station/${stationId}`,
{
name: name || "",
system_name: name || "", // system_name is often derived from name
system_name: name || "",
description: description || "",
address: address || "",
...commonDataPayload,
@@ -507,7 +502,6 @@ class StationsStore {
return response.data;
};
// Reset editStationData when navigating away or after saving
resetEditStationData = () => {
this.editStationData = {
ru: {

View 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>
);
});

View File

@@ -2,3 +2,4 @@ export * from "./TabPanel";
export * from "./BackButton";
export * from "./Modal";
export * from "./CoordinatesInput";
export * from "./AnimatedCircleButton";

View File

@@ -11,8 +11,8 @@ import {
devicesStore,
Modal,
snapshotStore,
vehicleStore, // Not directly used in this component's rendering logic anymore
} from "@shared"; // Assuming @shared exports these
vehicleStore,
} from "@shared";
import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite";
import { Button, Checkbox, Typography } from "@mui/material";
@@ -23,12 +23,10 @@ import { useNavigate } from "react-router-dom";
export type ConnectedDevice = string;
interface Snapshot {
ID: string; // Assuming ID is string based on usage
ID: string;
Name: string;
// Add other snapshot properties if needed
}
// --- HELPER FUNCTIONS ---
const formatDate = (dateString: string | null) => {
if (!dateString) return "Нет данных";
try {
@@ -76,12 +74,7 @@ function createData(
};
}
// This function transforms the raw device data (which includes vehicle and device_status)
// into the format expected by the table. It now filters for devices that have a UUID.
const transformDevicesToRows = (
vehicles: Vehicle[]
// devices: ConnectedDevice[]
): TableRowData[] => {
const transformDevicesToRows = (vehicles: Vehicle[]): TableRowData[] => {
return vehicles.map((vehicle) => {
const uuid = vehicle.vehicle.uuid;
if (!uuid)
@@ -115,26 +108,21 @@ export const DevicesTable = observer(() => {
} = devicesStore;
const { snapshots, getSnapshots } = snapshotStore;
const { getVehicles, vehicles } = vehicleStore; // Removed as devicesStore.devices should be the source of truth
const { getVehicles, vehicles } = vehicleStore;
const { devices } = devicesStore;
const navigate = useNavigate();
const [selectedDeviceUuids, setSelectedDeviceUuids] = useState<string[]>([]);
// Transform the raw devices data into rows suitable for the table
// This will also filter out devices without a UUID, as those cannot be acted upon.
const currentTableRows = transformDevicesToRows(
vehicles.data as Vehicle[]
// devices as ConnectedDevice[]
);
const currentTableRows = transformDevicesToRows(vehicles.data as Vehicle[]);
useEffect(() => {
const fetchData = async () => {
await getVehicles(); // Not strictly needed if devicesStore.devices is populated correctly by getDevices
await getDevices(); // This should fetch the combined vehicle/device_status data
await getVehicles();
await getDevices();
await getSnapshots();
};
fetchData();
}, [getDevices, getSnapshots]); // Added dependencies
}, [getDevices, getSnapshots]);
const isAllSelected =
currentTableRows.length > 0 &&
@@ -144,7 +132,6 @@ export const DevicesTable = observer(() => {
if (isAllSelected) {
setSelectedDeviceUuids([]);
} else {
// Select all device UUIDs from the *currently visible and selectable* rows
setSelectedDeviceUuids(
currentTableRows.map((row) => row.device_uuid ?? "")
);
@@ -171,14 +158,13 @@ export const DevicesTable = observer(() => {
};
const handleReloadStatus = async (uuid: string) => {
setSelectedDevice(uuid); // Sets the device in the store, useful for context elsewhere
setSelectedDevice(uuid);
try {
await authInstance.post(`/devices/${uuid}/request-status`);
await getVehicles();
await getDevices(); // Refresh devices to show updated status
await getDevices();
} catch (error) {
console.error(`Error requesting status for device ${uuid}:`, error);
// Optionally: show a user-facing error message
}
};
@@ -200,22 +186,16 @@ export const DevicesTable = observer(() => {
}
};
try {
// Create an array of promises for all snapshot requests
const snapshotPromises = selectedDeviceUuids.map((deviceUuid) => {
return send(deviceUuid);
});
// Wait for all promises to settle (either resolve or reject)
await Promise.allSettled(snapshotPromises);
// After all requests are attempted
await getDevices(); // Refresh the device list
setSelectedDeviceUuids([]); // Clear the selection
toggleSendSnapshotModal(); // Close the modal
await getDevices();
setSelectedDeviceUuids([]);
toggleSendSnapshotModal();
} catch (error) {
// This catch block might not be hit if Promise.allSettled is used,
// as it doesn't reject on individual promise failures.
// Individual errors should be handled if needed within the .map or by checking results.
console.error("Error in snapshot sending process:", error);
}
};
@@ -235,7 +215,7 @@ export const DevicesTable = observer(() => {
</div>
<div className="flex justify-end p-3 gap-2">
<Button
variant="outlined" // Changed to outlined for distinction
variant="outlined"
onClick={handleSelectAllDevices}
size="small"
>
@@ -286,7 +266,6 @@ export const DevicesTable = observer(() => {
)}
selected={selectedDeviceUuids.includes(row.device_uuid ?? "")}
onClick={(event) => {
// Allow clicking row to toggle checkbox, if not clicking on button
if (
(event.target as HTMLElement).closest("button") === null &&
(event.target as HTMLElement).closest(
@@ -308,7 +287,6 @@ export const DevicesTable = observer(() => {
}
}
// Only toggle checkbox if Shift key is not pressed
if (!event.shiftKey) {
handleSelectDevice(
{
@@ -317,7 +295,7 @@ export const DevicesTable = observer(() => {
row.device_uuid ?? ""
),
},
} as React.ChangeEvent<HTMLInputElement>, // Simulate event
} as React.ChangeEvent<HTMLInputElement>,
row.device_uuid ?? ""
);
}
@@ -445,7 +423,7 @@ export const DevicesTable = observer(() => {
</strong>
</Typography>
<div className="mt-2 flex flex-col gap-2 max-h-[300px] overflow-y-auto pr-2">
{snapshots && (snapshots as Snapshot[]).length > 0 ? ( // Cast snapshots
{snapshots && (snapshots as Snapshot[]).length > 0 ? (
(snapshots as Snapshot[]).map((snapshot) => (
<Button
variant="outlined"

View File

@@ -1,6 +1,6 @@
import React, { useRef, useState, DragEvent, useEffect } from "react";
import React, { useRef, DragEvent } from "react";
import { Paper, Box, Typography, Button, Tooltip } from "@mui/material";
import { X, Info, Plus } from "lucide-react"; // Assuming lucide-react for icons
import { X, Info, Plus } from "lucide-react";
import { editSightStore } from "@shared";
import { toast } from "react-toastify";
interface ImageUploadCardProps {
@@ -27,18 +27,9 @@ export const ImageUploadCard: React.FC<ImageUploadCardProps> = ({
tooltipText,
}) => {
const fileInputRef = useRef<HTMLInputElement>(null);
const [isDragOver, setIsDragOver] = useState(false);
const { setFileToUpload } = editSightStore;
useEffect(() => {
if (isDragOver) {
console.log("isDragOver");
}
}, [isDragOver]);
// --- Click to select file ---
const handleZoneClick = () => {
// Trigger the hidden file input click
fileInputRef.current?.click();
};
@@ -59,28 +50,25 @@ export const ImageUploadCard: React.FC<ImageUploadCardProps> = ({
toast.error("Пожалуйста, выберите изображение");
}
}
// Reset the input value so selecting the same file again triggers change
event.target.value = "";
};
const token = localStorage.getItem("token");
// --- Drag and Drop Handlers ---
const handleDragOver = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault(); // Crucial to allow a drop
event.preventDefault();
event.stopPropagation();
setIsDragOver(true);
};
const handleDragLeave = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
setIsDragOver(false);
};
const handleDrop = async (event: DragEvent<HTMLDivElement>) => {
event.preventDefault(); // Crucial to allow a drop
event.preventDefault();
event.stopPropagation();
setIsDragOver(false);
const files = event.dataTransfer.files;
if (files && files.length > 0) {
@@ -132,7 +120,6 @@ export const ImageUploadCard: React.FC<ImageUploadCardProps> = ({
cursor: imageUrl ? "pointer" : "default",
}}
onClick={onImageClick}
// Removed onClick on the main Box to avoid conflicts
>
{imageUrl && (
<button
@@ -165,7 +152,7 @@ export const ImageUploadCard: React.FC<ImageUploadCardProps> = ({
borderRadius: 1,
cursor: "pointer",
}}
onClick={handleZoneClick} // Click handler for the zone
onClick={handleZoneClick}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
@@ -179,8 +166,8 @@ export const ImageUploadCard: React.FC<ImageUploadCardProps> = ({
color="primary"
startIcon={<Plus color="white" size={18} />}
onClick={(e) => {
e.stopPropagation(); // Prevent `handleZoneClick` from firing
onSelectFileClick(); // This button might trigger a different modal
e.stopPropagation();
onSelectFileClick();
}}
>
Выбрать файл
@@ -191,7 +178,7 @@ export const ImageUploadCard: React.FC<ImageUploadCardProps> = ({
ref={fileInputRef}
onChange={handleFileInputChange}
style={{ display: "none" }}
accept="image/*" // Accept only image files
accept="image/*"
/>
</div>
)}

View File

@@ -48,21 +48,24 @@ export const Layout: React.FC<LayoutProps> = observer(({ children }) => {
<Box sx={{ display: "flex" }}>
<AppBar position="fixed" open={open}>
<Toolbar className="flex justify-between">
<IconButton
color="inherit"
aria-label="open drawer"
onClick={handleDrawerOpen}
edge="start"
sx={[
{
marginRight: 5,
},
open && { display: "none" },
]}
>
<Menu />
</IconButton>
<CitySelector />
<div className="flex items-center">
<IconButton
color="inherit"
aria-label="open drawer"
onClick={handleDrawerOpen}
edge="start"
sx={[
{
marginRight: 5,
},
open && { display: "none" },
]}
>
<Menu />
</IconButton>
<CitySelector />
</div>
<div className="flex gap-2 items-center">
<div className="flex flex-col gap-1">
{(() => {
@@ -114,7 +117,7 @@ export const Layout: React.FC<LayoutProps> = observer(({ children }) => {
}}
>
<img
src="/favicon_ship.png"
src="/favicon_ship.svg"
alt="logo"
width={40}
height={40}

View File

@@ -3,11 +3,9 @@ import { OrbitControls, Stage, useGLTF } from "@react-three/drei";
import { useEffect, Suspense } from "react";
import { Box, CircularProgress, Typography } from "@mui/material";
// Утилита для очистки кеша GLTF
const clearGLTFCache = (url?: string) => {
try {
if (url) {
// Если это blob URL, очищаем его из кеша
if (url.startsWith("blob:")) {
useGLTF.clear(url);
} else {
@@ -19,29 +17,23 @@ const clearGLTFCache = (url?: string) => {
}
};
// Утилита для проверки типа файла
const isValid3DFile = (url: string): boolean => {
try {
const urlObj = new URL(url);
const pathname = urlObj.pathname.toLowerCase();
const searchParams = urlObj.searchParams;
// Проверяем расширение файла в пути
const validExtensions = [".glb", ".gltf"];
const hasValidExtension = validExtensions.some((ext) =>
pathname.endsWith(ext)
);
// Проверяем параметры запроса на наличие типа файла
const fileType = searchParams.get("type") || searchParams.get("format");
const hasValidType =
fileType && ["glb", "gltf"].includes(fileType.toLowerCase());
// Если это blob URL, считаем его валидным (пользователь выбрал файл)
const isBlobUrl = url.startsWith("blob:");
// Если это URL с токеном и нет явного расширения, считаем валидным
// (предполагаем что сервер вернет правильный файл)
const hasToken = searchParams.has("token");
const isServerUrl = hasToken && !hasValidExtension;
@@ -51,7 +43,7 @@ const isValid3DFile = (url: string): boolean => {
return isValid;
} catch (error) {
console.warn("⚠️ isValid3DFile: Ошибка при проверке URL", error);
// В случае ошибки парсинга URL, считаем валидным (пусть useGLTF сам разберется)
return true;
}
};
@@ -63,13 +55,10 @@ type ModelViewerProps = {
};
const Model = ({ fileUrl }: { fileUrl: string }) => {
// Очищаем кеш перед загрузкой новой модели
useEffect(() => {
// Очищаем кеш для текущего URL
clearGLTFCache(fileUrl);
}, [fileUrl]);
// Проверяем валидность файла перед загрузкой (только для blob URL)
if (fileUrl.startsWith("blob:") && !isValid3DFile(fileUrl)) {
console.warn("⚠️ Model: Попытка загрузить невалидный 3D файл", { fileUrl });
throw new Error(`Файл не является корректной 3D моделью: ${fileUrl}`);
@@ -114,16 +103,13 @@ export const ThreeView = ({
height = "100%",
width = "100%",
}: ModelViewerProps) => {
// Проверяем валидность файла (только для blob URL)
useEffect(() => {
if (fileUrl.startsWith("blob:") && !isValid3DFile(fileUrl)) {
console.warn("⚠️ ThreeView: Невалидный 3D файл", { fileUrl });
}
}, [fileUrl]);
// Очищаем кеш при размонтировании и при смене URL
useEffect(() => {
// Очищаем кеш сразу при монтировании компонента
clearGLTFCache(fileUrl);
return () => {

View File

@@ -35,7 +35,6 @@ export class ThreeViewErrorBoundary extends Component<Props, State> {
props: Props,
state: State
): Partial<State> | null {
// Сбрасываем ошибку ТОЛЬКО при смене медиа (когда меняется ID в resetKey)
if (
props.resetKey !== state.lastResetKey &&
state.lastResetKey !== undefined
@@ -43,7 +42,6 @@ export class ThreeViewErrorBoundary extends Component<Props, State> {
const oldMediaId = String(state.lastResetKey).split("-")[0];
const newMediaId = String(props.resetKey).split("-")[0];
// Сбрасываем ошибку только если изменился ID медиа (пользователь выбрал другую модель)
if (oldMediaId !== newMediaId) {
return {
hasError: false,
@@ -52,9 +50,6 @@ export class ThreeViewErrorBoundary extends Component<Props, State> {
};
}
// Если изменился только счетчик (нажата кнопка "Попробовать снова"), обновляем lastResetKey
// но не сбрасываем ошибку автоматически - ждем результата загрузки
return {
lastResetKey: props.resetKey,
};
@@ -127,15 +122,12 @@ export class ThreeViewErrorBoundary extends Component<Props, State> {
};
handleReset = () => {
// Сначала сбрасываем состояние ошибки
this.setState(
{
hasError: false,
error: null,
},
() => {
// После того как состояние обновилось, вызываем callback для изменения resetKey
// Это приведет к пересозданию компонента и новой попытке загрузки
this.props.onReset?.();
}
);

View File

@@ -38,7 +38,7 @@ export function MediaViewer({
// Используем новый cache manager для очистки кеша
clearMediaTransitionCache(
previousMediaId,
media?.id || null,
media?.media_type
);

View File

@@ -1,158 +0,0 @@
// import { Box, Button, Paper, Typography } from "@mui/material";
// import { X, Upload } from "lucide-react";
// import { useCallback, useState } from "react";
// import { useDropzone } from "react-dropzone";
// import { UploadMediaDialog } from "@shared";
// import { createSightStore } from "@shared";
// interface MediaUploadBoxProps {
// title: string;
// tooltip?: string;
// mediaId: string | null;
// onMediaSelect: (mediaId: string) => void;
// onMediaRemove: () => void;
// onPreviewClick: (mediaId: string) => void;
// token: string;
// type: "thumbnail" | "watermark_lu" | "watermark_rd";
// }
// export const MediaUploadBox = ({
// title,
// tooltip,
// mediaId,
// onMediaSelect,
// onMediaRemove,
// onPreviewClick,
// token,
// type,
// }: MediaUploadBoxProps) => {
// const [uploadMediaOpen, setUploadMediaOpen] = useState(false);
// const [fileToUpload, setFileToUpload] = useState<File | null>(null);
// const onDrop = useCallback((acceptedFiles: File[]) => {
// if (acceptedFiles.length > 0) {
// setFileToUpload(acceptedFiles[0]);
// setUploadMediaOpen(true);
// }
// }, []);
// const { getRootProps, getInputProps, isDragActive } = useDropzone({
// onDrop,
// accept: {
// "image/*": [".png", ".jpg", ".jpeg", ".gif"],
// },
// multiple: false,
// });
// const handleUploadComplete = async (media: {
// id: string;
// filename: string;
// media_name?: string;
// media_type: number;
// }) => {
// onMediaSelect(media.id);
// };
// return (
// <>
// <Paper
// elevation={2}
// sx={{
// padding: 2,
// display: "flex",
// flexDirection: "column",
// alignItems: "center",
// gap: 1,
// flex: 1,
// minWidth: 150,
// }}
// >
// <Box sx={{ display: "flex", alignItems: "center" }}>
// <Typography variant="subtitle2" gutterBottom sx={{ mb: 0, mr: 0.5 }}>
// {title}
// </Typography>
// </Box>
// <Box
// {...getRootProps()}
// sx={{
// position: "relative",
// width: "200px",
// height: "200px",
// display: "flex",
// alignItems: "center",
// justifyContent: "center",
// borderRadius: 1,
// mb: 1,
// cursor: mediaId ? "pointer" : "default",
// border: isDragActive ? "2px dashed #1976d2" : "none",
// backgroundColor: isDragActive
// ? "rgba(25, 118, 210, 0.04)"
// : "transparent",
// transition: "all 0.2s ease",
// }}
// >
// <input {...getInputProps()} />
// {mediaId && (
// <button
// className="absolute top-2 right-2 z-10"
// onClick={(e) => {
// e.stopPropagation();
// onMediaRemove();
// }}
// >
// <X color="red" />
// </button>
// )}
// {mediaId ? (
// <img
// src={`${
// import.meta.env.VITE_KRBL_MEDIA
// }${mediaId}/download?token=${token}`}
// alt={title}
// style={{ maxWidth: "100%", maxHeight: "100%" }}
// onClick={(e) => {
// e.stopPropagation();
// onPreviewClick(mediaId);
// }}
// />
// ) : (
// <div className="w-full flex flex-col items-center justify-center gap-3">
// <div
// className={`w-full h-20 border rounded-md flex flex-col items-center transition-all duration-300 justify-center border-dashed ${
// isDragActive
// ? "border-blue-500 bg-blue-50"
// : "border-gray-300"
// } cursor-pointer hover:bg-gray-100`}
// >
// <Upload size={24} className="mb-2" />
// <p>
// {isDragActive ? "Отпустите файл здесь" : "Перетащите файл"}
// </p>
// </div>
// <p>или</p>
// <Button
// variant="contained"
// color="primary"
// onClick={(e) => {
// e.stopPropagation();
// onMediaSelect("");
// }}
// >
// Выбрать файл
// </Button>
// </div>
// )}
// </Box>
// </Paper>
// <UploadMediaDialog
// open={uploadMediaOpen}
// onClose={() => {
// setUploadMediaOpen(false);
// setFileToUpload(null);
// }}
// afterUpload={handleUploadComplete}
// />
// </>
// );
// };

View File

@@ -1,4 +1,3 @@
// @widgets/LeftWidgetTab.tsx
import { Box, Button, TextField, Paper, Typography } from "@mui/material";
import {
BackButton,
@@ -50,17 +49,6 @@ export const CreateLeftTab = observer(
const [isSelectMediaDialogOpen, setIsSelectMediaDialogOpen] =
useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
// const handleMediaSelected = useCallback(() => {
// // При выборе медиа, обновляем данные для ТЕКУЩЕГО ЯЗЫКА
// // сохраняя текущие heading и body.
// updateSightInfo(language, {
// left: {
// heading: data.left.heading,
// body: data.left.body,
// },
// });
// setIsSelectMediaDialogOpen(false);
// }, [language, data.left.heading, data.left.body]);
const handleCloseArticleDialog = useCallback(() => {
setIsSelectArticleDialogOpen(false);

View File

@@ -13,28 +13,27 @@ import {
languageStore,
SelectArticleModal,
TabPanel,
SelectMediaDialog, // Import
SelectMediaDialog,
UploadMediaDialog,
Media, // Import
Media,
} from "@shared";
import {
LanguageSwitcher,
MediaArea, // Import
MediaAreaForSight, // Import
MediaArea,
MediaAreaForSight,
ReactMarkdownComponent,
ReactMarkdownEditor,
DeleteModal,
} from "@widgets";
import { ImagePlus, Plus, Save, Trash2, Unlink, X } from "lucide-react"; // Import X
import { ImagePlus, Plus, Save, Trash2, Unlink, X } from "lucide-react";
import { observer } from "mobx-react-lite";
import { useState, useEffect } from "react"; // Added useEffect
import { useState, useEffect } from "react";
import { MediaViewer } from "../../MediaViewer/index";
import { toast } from "react-toastify";
import { authInstance } from "@shared";
import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd";
type MediaItemShared = {
// Define if not already available from @shared
id: string;
filename: string;
media_name?: string;
@@ -52,14 +51,14 @@ export const CreateRightTab = observer(
unlinkPreviewMedia,
createLinkWithRightArticle,
deleteRightArticleMedia,
setFileToUpload, // From store
setUploadMediaOpen, // From store
uploadMediaOpen, // From store
unlinkRightAritcle, // Corrected spelling
setFileToUpload,
setUploadMediaOpen,
uploadMediaOpen,
unlinkRightAritcle,
deleteRightArticle,
linkExistingRightArticle,
createSight,
clearCreateSight, // For resetting form
clearCreateSight,
updateRightArticles,
} = createSightStore;
const { language } = languageStore;
@@ -78,7 +77,7 @@ export const CreateRightTab = observer(
>(null);
const [previewMedia, setPreviewMedia] = useState<Media | null>(null);
// Reset activeArticleIndex if language changes and index is out of bounds
useEffect(() => {
if (sight.preview_media) {
const fetchMedia = async () => {
@@ -97,7 +96,7 @@ export const CreateRightTab = observer(
activeArticleIndex >= sight[language].right.length
) {
setActiveArticleIndex(null);
setType("media"); // Default back to media preview if selected article disappears
setType("media");
}
}, [language, sight[language].right, activeArticleIndex]);
@@ -113,10 +112,9 @@ export const CreateRightTab = observer(
try {
await createSight(language);
toast.success("Достопримечательность успешно создана!");
clearCreateSight(); // Reset form
clearCreateSight();
setActiveArticleIndex(null);
setType("media");
// Potentially navigate away: history.push('/sights-list');
} catch (error) {
console.error("Failed to save sight:", error);
toast.error("Ошибка при создании достопримечательности.");
@@ -132,7 +130,7 @@ export const CreateRightTab = observer(
handleCloseMenu();
try {
const newArticleId = await createNewRightArticle();
// Automatically select the new article if ID is returned
const newIndex = sight[language].right.findIndex(
(a) => a.id === newArticleId
);
@@ -140,7 +138,6 @@ export const CreateRightTab = observer(
setActiveArticleIndex(newIndex);
setType("article");
} else {
// Fallback if findIndex fails (should not happen if store updates correctly)
setActiveArticleIndex(sight[language].right.length - 1);
setType("article");
}
@@ -156,7 +153,7 @@ export const CreateRightTab = observer(
const linkedArticleId = await linkExistingRightArticle(
selectedArticleId
);
setSelectArticleDialogOpen(false); // Close dialog
setSelectArticleDialogOpen(false);
const newIndex = sight[language].right.findIndex(
(a) => a.id === linkedArticleId
);
@@ -174,7 +171,6 @@ export const CreateRightTab = observer(
? sight[language].right[activeArticleIndex]
: null;
// Media Handling for Dialogs
const handleOpenUploadMedia = () => {
setUploadMediaOpen(true);
};
@@ -203,7 +199,6 @@ export const CreateRightTab = observer(
};
const handleMediaUploaded = async (media: MediaItemShared) => {
// After UploadMediaDialog finishes
setUploadMediaOpen(false);
setFileToUpload(null);
if (mediaTarget === "sightPreview") {
@@ -211,36 +206,25 @@ export const CreateRightTab = observer(
} else if (mediaTarget === "rightArticle" && currentRightArticle) {
await createLinkWithRightArticle(media, currentRightArticle.id);
}
setMediaTarget(null); // Reset target
setMediaTarget(null);
};
const handleDragEnd = (result: any) => {
const { source, destination } = result;
// 1. Guard clause: If dropped outside any droppable area, do nothing.
if (!destination) return;
// Extract source and destination indices
const sourceIndex = source.index;
const destinationIndex = destination.index;
// 2. Guard clause: If dropped in the same position, do nothing.
if (sourceIndex === destinationIndex) return;
// 3. Create a new array with reordered articles:
// - Create a shallow copy of the current articles array.
// This is important for immutability and triggering re-renders.
const newRightArticles = [...sight[language].right];
// - Remove the dragged article from its original position.
// `splice` returns an array of removed items, so we destructure the first (and only) one.
const [movedArticle] = newRightArticles.splice(sourceIndex, 1);
// - Insert the moved article into its new position.
newRightArticles.splice(destinationIndex, 0, movedArticle);
// 4. Update the store with the new order:
// This will typically trigger a re-render of the component with the updated list.
updateRightArticles(newRightArticles);
};
@@ -254,7 +238,7 @@ export const CreateRightTab = observer(
height: "100%",
minHeight: "calc(100vh - 200px)",
gap: 2,
paddingBottom: "70px", // Space for the save button
paddingBottom: "70px",
position: "relative",
}}
>
@@ -264,7 +248,6 @@ export const CreateRightTab = observer(
</div>
<Box sx={{ display: "flex", flexGrow: 1, gap: 2.5 }}>
{/* Left Column: Navigation & Article List */}
<Box className="flex flex-col w-[75%] gap-2">
<Box className="w-full flex gap-2 ">
<Box className="relative w-[20%] h-[70vh] flex flex-col rounded-2xl overflow-y-auto gap-3 border border-gray-300 p-3">
@@ -272,7 +255,6 @@ export const CreateRightTab = observer(
<Box
onClick={() => {
setType("media");
// setActiveArticleIndex(null); // Optional: deselect article when switching to general media view
}}
className={`w-full p-4 rounded-2xl cursor-pointer text-sm hover:bg-gray-300 transition-all duration-300 ${
type === "media"
@@ -364,7 +346,6 @@ export const CreateRightTab = observer(
</Menu>
</Box>
{/* Main content area: Article Editor or Sight Media Preview */}
{type === "article" && currentRightArticle ? (
<Box className="w-[80%] border border-gray-300 p-3 flex flex-col gap-2 overflow-hidden">
<Box className="flex justify-end gap-2 mb-1 flex-shrink-0">
@@ -375,7 +356,7 @@ export const CreateRightTab = observer(
startIcon={<Unlink color="white" size={18} />}
onClick={() => {
if (currentRightArticle) {
unlinkRightAritcle(currentRightArticle.id); // Corrected function name
unlinkRightAritcle(currentRightArticle.id);
setActiveArticleIndex(null);
setType("media");
}
@@ -435,7 +416,7 @@ export const CreateRightTab = observer(
/>
</Box>
<MediaArea
articleId={currentRightArticle.id} // Needs a real ID
articleId={currentRightArticle.id}
mediaIds={currentRightArticle.media || []}
onFilesDrop={(files) => {
if (files.length > 0) {
@@ -507,7 +488,6 @@ export const CreateRightTab = observer(
</Box>
</Box>
{/* Right Column: Live Preview */}
<Box className="w-[25%] mr-10">
{type === "article" && activeArticleIndex !== null && (
<Paper
@@ -662,12 +642,11 @@ export const CreateRightTab = observer(
</Box>
</Box>
{/* Sticky Save Button Footer */}
<Box
sx={{
position: "absolute",
bottom: "-20px",
left: 0, // ensure it spans from left
left: 0,
right: 0,
padding: 2,
backgroundColor: "background.paper",
@@ -689,19 +668,17 @@ export const CreateRightTab = observer(
</Box>
</Box>
{/* Modals */}
<SelectArticleModal
open={selectArticleDialogOpen}
onClose={() => setSelectArticleDialogOpen(false)}
onSelectArticle={handleSelectExistingArticleAndLink}
// Pass IDs of already linked/added right articles to exclude them from selection
linkedArticleIds={sight[language].right.map((article) => article.id)}
/>
<UploadMediaDialog
open={uploadMediaOpen} // From store
open={uploadMediaOpen}
onClose={() => {
setUploadMediaOpen(false);
setFileToUpload(null); // Clear file if dialog is closed without upload
setFileToUpload(null);
setMediaTarget(null);
}}
contextObjectName={sight[language].name}
@@ -712,7 +689,7 @@ export const CreateRightTab = observer(
? sight[language].right[activeArticleIndex].heading
: undefined
}
afterUpload={handleMediaUploaded} // This will use the mediaTarget
afterUpload={handleMediaUploaded}
/>
<SelectMediaDialog
open={isSelectMediaDialogOpen}

View File

@@ -37,8 +37,8 @@ import { useEffect, useState } from "react";
import { toast } from "react-toastify";
// Компонент предупреждающего окна (перенесен сюда)
import { SaveWithoutCityAgree } from "@widgets";
import { LinkedStations } from "@pages";
export const InformationTab = observer(
({ value, index }: { value: number; index: number }) => {
@@ -62,7 +62,7 @@ export const InformationTab = observer(
"thumbnail" | "watermark_lu" | "watermark_rd" | "video_preview" | null
>(null);
const { cities } = cityStore;
// НОВОЕ СОСТОЯНИЕ ДЛЯ ПРЕДУПРЕЖДАЮЩЕГО ОКНА
const [isSaveWarningOpen, setIsSaveWarningOpen] = useState(false);
useEffect(() => {}, [hardcodeType]);
@@ -119,16 +119,14 @@ export const InformationTab = observer(
updateSightInfo(language, content, common);
};
// НОВАЯ ФУНКЦИЯ: Фактическое сохранение (вызывается после подтверждения)
const executeSave = async () => {
await updateSight();
toast.success("Достопримечательность сохранена");
};
// ОБНОВЛЕННАЯ ФУНКЦИЯ: Проверка и вызов окна или сохранения
const handleSave = async () => {
const isCityMissing = !sight.common.city_id;
// Проверяем названия на всех языках
const isNameMissing = !sight.ru.name || !sight.en.name || !sight.zh.name;
if (isCityMissing || isNameMissing) {
@@ -139,13 +137,11 @@ export const InformationTab = observer(
await executeSave();
};
// Обработчик "Да" в предупреждающем окне
const handleConfirmSave = async () => {
setIsSaveWarningOpen(false);
await executeSave();
};
// Обработчик "Нет" в предупреждающем окне
const handleCancelSave = () => {
setIsSaveWarningOpen(false);
};
@@ -275,6 +271,16 @@ export const InformationTab = observer(
/>
</Box>
<Box sx={{ width: "80%" }}>
{sight.common.id !== 0 && (
<LinkedStations
parentId={sight.common.id}
fields={[{ label: "Название", data: "name" }]}
type="edit"
/>
)}
</Box>
<Box
sx={{
display: "flex",
@@ -431,7 +437,7 @@ export const InformationTab = observer(
variant="contained"
color="success"
startIcon={<Save color="white" size={18} />}
onClick={handleSave} // Используем новую функцию-обработчик
onClick={handleSave}
>
Сохранить
</Button>
@@ -538,7 +544,6 @@ export const InformationTab = observer(
</Dialog>
)}
{/* ИНТЕГРИРОВАННОЕ ПРЕДУПРЕЖДАЮЩЕЕ ОКНО */}
{isSaveWarningOpen && (
<SaveWithoutCityAgree
blocker={{
@@ -550,4 +555,4 @@ export const InformationTab = observer(
</>
);
}
);
);

View File

@@ -118,7 +118,7 @@ export const RightWidgetTab = observer(
try {
const newArticleId = await createNewRightArticle();
handleClose();
// Automatically select the newly created article
const newIndex = sight[language].right.findIndex(
(article) => article.id === newArticleId
);
@@ -144,7 +144,7 @@ export const RightWidgetTab = observer(
try {
const linkedArticleId = await linkArticle(id);
handleCloseSelectModal();
// Automatically select the newly linked article
const newIndex = sight[language].right.findIndex(
(article) => article.id === linkedArticleId
);
@@ -177,30 +177,19 @@ export const RightWidgetTab = observer(
const handleDragEnd = (result: DropResult) => {
const { source, destination } = result;
// 1. Guard clause: If dropped outside any droppable area, do nothing.
if (!destination) return;
// Extract source and destination indices
const sourceIndex = source.index;
const destinationIndex = destination.index;
// 2. Guard clause: If dropped in the same position, do nothing.
if (sourceIndex === destinationIndex) return;
// 3. Create a new array with reordered articles:
// - Create a shallow copy of the current articles array.
// This is important for immutability and triggering re-renders.
const newRightArticles = [...sight[language].right];
// - Remove the dragged article from its original position.
// `splice` returns an array of removed items, so we destructure the first (and only) one.
const [movedArticle] = newRightArticles.splice(sourceIndex, 1);
// - Insert the moved article into its new position.
newRightArticles.splice(destinationIndex, 0, movedArticle);
// 4. Update the store with the new order:
// This will typically trigger a re-render of the component with the updated list.
updateRightArticles(newRightArticles);
};

View File

@@ -11,6 +11,7 @@ interface VideoPreviewCardProps {
onDeleteVideoClick: () => void;
onSelectVideoClick: (file?: File) => void;
tooltipText?: string;
className?: string;
}
export const VideoPreviewCard: React.FC<VideoPreviewCardProps> = ({
@@ -20,15 +21,15 @@ export const VideoPreviewCard: React.FC<VideoPreviewCardProps> = ({
onDeleteVideoClick,
onSelectVideoClick,
tooltipText,
className,
}) => {
const fileInputRef = useRef<HTMLInputElement>(null);
const [isDragOver, setIsDragOver] = useState(false);
const token = localStorage.getItem("token");
useEffect(() => {}, [isDragOver]);
// --- Click to select file ---
const handleZoneClick = () => {
// Trigger the hidden file input click
fileInputRef.current?.click();
};
@@ -38,19 +39,17 @@ export const VideoPreviewCard: React.FC<VideoPreviewCardProps> = ({
const file = event.target.files?.[0];
if (file) {
if (file.type.startsWith("video/")) {
// Открываем диалог загрузки медиа с файлом видео
onSelectVideoClick(file);
} else {
toast.error("Пожалуйста, выберите видео файл");
}
}
// Reset the input value so selecting the same file again triggers change
event.target.value = "";
};
// --- Drag and Drop Handlers ---
const handleDragOver = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault(); // Crucial to allow a drop
event.preventDefault();
event.stopPropagation();
setIsDragOver(true);
};
@@ -62,7 +61,7 @@ export const VideoPreviewCard: React.FC<VideoPreviewCardProps> = ({
};
const handleDrop = async (event: DragEvent<HTMLDivElement>) => {
event.preventDefault(); // Crucial to allow a drop
event.preventDefault();
event.stopPropagation();
setIsDragOver(false);
@@ -70,7 +69,6 @@ export const VideoPreviewCard: React.FC<VideoPreviewCardProps> = ({
if (files && files.length > 0) {
const file = files[0];
if (file.type.startsWith("video/")) {
// Открываем диалог загрузки медиа с файлом видео
onSelectVideoClick(file);
} else {
toast.error("Пожалуйста, выберите видео файл");
@@ -89,7 +87,10 @@ export const VideoPreviewCard: React.FC<VideoPreviewCardProps> = ({
gap: 1,
flex: 1,
minWidth: 150,
width: "min-content",
mx: "auto",
}}
className={className}
>
<Box sx={{ display: "flex", alignItems: "center" }}>
<Typography variant="subtitle2" gutterBottom sx={{ mb: 0, mr: 0.5 }}>
@@ -127,7 +128,10 @@ export const VideoPreviewCard: React.FC<VideoPreviewCardProps> = ({
</button>
)}
{videoId ? (
<Box sx={{ position: "relative", width: "100%", height: "100%" }}>
<Box
sx={{ position: "relative", width: "100%", height: "100%" }}
className={className}
>
<video
src={`${
import.meta.env.VITE_KRBL_MEDIA
@@ -167,7 +171,7 @@ export const VideoPreviewCard: React.FC<VideoPreviewCardProps> = ({
borderRadius: 1,
cursor: "pointer",
}}
onClick={handleZoneClick} // Click handler for the zone
onClick={handleZoneClick}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
@@ -181,8 +185,8 @@ export const VideoPreviewCard: React.FC<VideoPreviewCardProps> = ({
color="primary"
startIcon={<Plus color="white" size={18} />}
onClick={(e) => {
e.stopPropagation(); // Prevent `handleZoneClick` from firing
onSelectVideoClick(); // This button triggers the media selection dialog
e.stopPropagation();
onSelectVideoClick();
}}
>
Выбрать файл
@@ -193,7 +197,7 @@ export const VideoPreviewCard: React.FC<VideoPreviewCardProps> = ({
ref={fileInputRef}
onChange={handleFileInputChange}
style={{ display: "none" }}
accept="video/*" // Accept only video files
accept="video/*"
/>
</div>
)}

File diff suppressed because one or more lines are too long

View File

@@ -1,11 +1,13 @@
import { defineConfig } from "vite";
import { defineConfig, type UserConfigExport } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import path from "path";
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
plugins: [
react(),
tailwindcss(),
],
resolve: {
alias: {
"@shared": path.resolve(__dirname, "src/shared"),
@@ -16,4 +18,9 @@ export default defineConfig({
"@app": path.resolve(__dirname, "src/app"),
},
},
build: {
chunkSizeWarningLimit: 5000,
},
});

2417
yarn.lock

File diff suppressed because it is too large Load Diff