Compare commits
5 Commits
2b48ade2f1
...
#18
| Author | SHA1 | Date | |
|---|---|---|---|
| 1917b2cf5a | |||
| 5298fb9f60 | |||
| c95a6517e9 | |||
| 79f523e9cb | |||
| 90f3d66b22 |
@@ -16,12 +16,7 @@ import {
|
|||||||
SnapshotListPage,
|
SnapshotListPage,
|
||||||
CarrierListPage,
|
CarrierListPage,
|
||||||
StationListPage,
|
StationListPage,
|
||||||
// VehicleListPage,
|
|
||||||
ArticleListPage,
|
ArticleListPage,
|
||||||
|
|
||||||
// CountryPreviewPage,
|
|
||||||
// VehiclePreviewPage,
|
|
||||||
// CarrierPreviewPage,
|
|
||||||
SnapshotCreatePage,
|
SnapshotCreatePage,
|
||||||
CountryCreatePage,
|
CountryCreatePage,
|
||||||
CityCreatePage,
|
CityCreatePage,
|
||||||
@@ -31,7 +26,6 @@ import {
|
|||||||
CityEditPage,
|
CityEditPage,
|
||||||
UserCreatePage,
|
UserCreatePage,
|
||||||
UserEditPage,
|
UserEditPage,
|
||||||
// VehicleEditPage,
|
|
||||||
CarrierEditPage,
|
CarrierEditPage,
|
||||||
StationCreatePage,
|
StationCreatePage,
|
||||||
StationPreviewPage,
|
StationPreviewPage,
|
||||||
@@ -75,7 +69,6 @@ const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
|||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Чтобы очистка сторов происходила при смене локации
|
|
||||||
const ClearStoresWrapper: React.FC<{ children: React.ReactNode }> = ({
|
const ClearStoresWrapper: React.FC<{ children: React.ReactNode }> = ({
|
||||||
children,
|
children,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -116,65 +109,45 @@ const router = createBrowserRouter([
|
|||||||
children: [
|
children: [
|
||||||
{ index: true, element: <MainPage /> },
|
{ index: true, element: <MainPage /> },
|
||||||
|
|
||||||
// Sight
|
|
||||||
{ path: "sight", element: <SightListPage /> },
|
{ path: "sight", element: <SightListPage /> },
|
||||||
{ path: "sight/create", element: <CreateSightPage /> },
|
{ path: "sight/create", element: <CreateSightPage /> },
|
||||||
{ path: "sight/:id/edit", element: <EditSightPage /> },
|
{ path: "sight/:id/edit", element: <EditSightPage /> },
|
||||||
|
|
||||||
// Device
|
|
||||||
{ path: "devices", element: <DevicesPage /> },
|
{ path: "devices", element: <DevicesPage /> },
|
||||||
|
|
||||||
// Map
|
|
||||||
{ path: "map", element: <MapPage /> },
|
{ path: "map", element: <MapPage /> },
|
||||||
|
|
||||||
// Media
|
|
||||||
{ path: "media", element: <MediaListPage /> },
|
{ path: "media", element: <MediaListPage /> },
|
||||||
{ path: "media/:id", element: <MediaPreviewPage /> },
|
{ path: "media/:id", element: <MediaPreviewPage /> },
|
||||||
{ path: "media/:id/edit", element: <MediaEditPage /> },
|
{ path: "media/:id/edit", element: <MediaEditPage /> },
|
||||||
|
|
||||||
// Country
|
|
||||||
{ path: "country", element: <CountryListPage /> },
|
{ path: "country", element: <CountryListPage /> },
|
||||||
{ path: "country/create", element: <CountryCreatePage /> },
|
{ path: "country/create", element: <CountryCreatePage /> },
|
||||||
{ path: "country/add", element: <CountryAddPage /> },
|
{ path: "country/add", element: <CountryAddPage /> },
|
||||||
// { path: "country/:id", element: <CountryPreviewPage /> },
|
|
||||||
{ path: "country/:id/edit", element: <CountryEditPage /> },
|
{ path: "country/:id/edit", element: <CountryEditPage /> },
|
||||||
// City
|
|
||||||
{ path: "city", element: <CityListPage /> },
|
{ path: "city", element: <CityListPage /> },
|
||||||
{ path: "city/create", element: <CityCreatePage /> },
|
{ path: "city/create", element: <CityCreatePage /> },
|
||||||
// { path: "city/:id", element: <CityPreviewPage /> },
|
|
||||||
{ path: "city/:id/edit", element: <CityEditPage /> },
|
{ path: "city/:id/edit", element: <CityEditPage /> },
|
||||||
// Route
|
|
||||||
{ path: "route", element: <RouteListPage /> },
|
{ path: "route", element: <RouteListPage /> },
|
||||||
{ path: "route/create", element: <RouteCreatePage /> },
|
{ path: "route/create", element: <RouteCreatePage /> },
|
||||||
{ path: "route/:id/edit", element: <RouteEditPage /> },
|
{ path: "route/:id/edit", element: <RouteEditPage /> },
|
||||||
|
|
||||||
// User
|
|
||||||
{ path: "user", element: <UserListPage /> },
|
{ path: "user", element: <UserListPage /> },
|
||||||
{ path: "user/create", element: <UserCreatePage /> },
|
{ path: "user/create", element: <UserCreatePage /> },
|
||||||
{ path: "user/:id/edit", element: <UserEditPage /> },
|
{ path: "user/:id/edit", element: <UserEditPage /> },
|
||||||
// Snapshot
|
|
||||||
{ path: "snapshot", element: <SnapshotListPage /> },
|
{ path: "snapshot", element: <SnapshotListPage /> },
|
||||||
{ path: "snapshot/create", element: <SnapshotCreatePage /> },
|
{ path: "snapshot/create", element: <SnapshotCreatePage /> },
|
||||||
|
|
||||||
// Carrier
|
|
||||||
{ path: "carrier", element: <CarrierListPage /> },
|
{ path: "carrier", element: <CarrierListPage /> },
|
||||||
{ path: "carrier/create", element: <CarrierCreatePage /> },
|
{ path: "carrier/create", element: <CarrierCreatePage /> },
|
||||||
// { path: "carrier/:id", element: <CarrierPreviewPage /> },
|
|
||||||
{ path: "carrier/:id/edit", element: <CarrierEditPage /> },
|
{ path: "carrier/:id/edit", element: <CarrierEditPage /> },
|
||||||
// Station
|
|
||||||
{ path: "station", element: <StationListPage /> },
|
{ path: "station", element: <StationListPage /> },
|
||||||
{ path: "station/create", element: <StationCreatePage /> },
|
{ path: "station/create", element: <StationCreatePage /> },
|
||||||
{ path: "station/:id", element: <StationPreviewPage /> },
|
{ path: "station/:id", element: <StationPreviewPage /> },
|
||||||
{ path: "station/:id/edit", element: <StationEditPage /> },
|
{ path: "station/:id/edit", element: <StationEditPage /> },
|
||||||
// Vehicle
|
|
||||||
// { path: "vehicle", element: <VehicleListPage /> },
|
|
||||||
{ path: "vehicle/create", element: <VehicleCreatePage /> },
|
{ path: "vehicle/create", element: <VehicleCreatePage /> },
|
||||||
// { path: "vehicle/:id", element: <VehiclePreviewPage /> },
|
|
||||||
// { path: "vehicle/:id/edit", element: <VehicleEditPage /> },
|
|
||||||
// Article
|
|
||||||
{ path: "article", element: <ArticleListPage /> },
|
{ path: "article", element: <ArticleListPage /> },
|
||||||
{ path: "article/:id", element: <ArticlePreviewPage /> },
|
{ path: "article/:id", element: <ArticlePreviewPage /> },
|
||||||
// { path: "media/create", element: <CreateMediaPage /> },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ export const CarrierCreatePage = observer(() => {
|
|||||||
languageStore.setLanguage("ru");
|
languageStore.setLanguage("ru");
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Автоматически устанавливаем выбранный город при загрузке страницы
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedCityId && !createCarrierData.city_id) {
|
if (selectedCityId && !createCarrierData.city_id) {
|
||||||
setCreateCarrierData(
|
setCreateCarrierData(
|
||||||
|
|||||||
@@ -74,7 +74,6 @@ export const CarrierEditPage = observer(() => {
|
|||||||
mediaStore.getMedia();
|
mediaStore.getMedia();
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// Устанавливаем русский язык при загрузке страницы
|
|
||||||
languageStore.setLanguage("ru");
|
languageStore.setLanguage("ru");
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ export const CityEditPage = observer(() => {
|
|||||||
const { getMedia, getOneMedia } = mediaStore;
|
const { getMedia, getOneMedia } = mediaStore;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Устанавливаем русский язык при загрузке страницы
|
|
||||||
languageStore.setLanguage("ru");
|
languageStore.setLanguage("ru");
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -64,12 +63,11 @@ export const CityEditPage = observer(() => {
|
|||||||
(async () => {
|
(async () => {
|
||||||
if (id) {
|
if (id) {
|
||||||
await getCountries("ru");
|
await getCountries("ru");
|
||||||
// Fetch data for all languages
|
|
||||||
const ruData = await getCity(id as string, "ru");
|
const ruData = await getCity(id as string, "ru");
|
||||||
const enData = await getCity(id as string, "en");
|
const enData = await getCity(id as string, "en");
|
||||||
const zhData = await getCity(id as string, "zh");
|
const zhData = await getCity(id as string, "zh");
|
||||||
|
|
||||||
// Set data for each language
|
|
||||||
setEditCityData(ruData.name, ruData.country_code, ruData.arms, "ru");
|
setEditCityData(ruData.name, ruData.country_code, ruData.arms, "ru");
|
||||||
setEditCityData(enData.name, enData.country_code, enData.arms, "en");
|
setEditCityData(enData.name, enData.country_code, enData.arms, "en");
|
||||||
setEditCityData(zhData.name, zhData.country_code, zhData.arms, "zh");
|
setEditCityData(zhData.name, zhData.country_code, zhData.arms, "zh");
|
||||||
@@ -207,7 +205,7 @@ export const CityEditPage = observer(() => {
|
|||||||
open={isSelectMediaOpen}
|
open={isSelectMediaOpen}
|
||||||
onClose={() => setIsSelectMediaOpen(false)}
|
onClose={() => setIsSelectMediaOpen(false)}
|
||||||
onSelectMedia={handleMediaSelect}
|
onSelectMedia={handleMediaSelect}
|
||||||
mediaType={1} // Тип медиа для иконок
|
mediaType={1}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<UploadMediaDialog
|
<UploadMediaDialog
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ export const CountryEditPage = observer(() => {
|
|||||||
countryStore;
|
countryStore;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Устанавливаем русский язык при загрузке страницы
|
|
||||||
languageStore.setLanguage("ru");
|
languageStore.setLanguage("ru");
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -36,12 +35,10 @@ export const CountryEditPage = observer(() => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
if (id) {
|
if (id) {
|
||||||
// Fetch data for all languages
|
|
||||||
const ruData = await getCountry(id as string, "ru");
|
const ruData = await getCountry(id as string, "ru");
|
||||||
const enData = await getCountry(id as string, "en");
|
const enData = await getCountry(id as string, "en");
|
||||||
const zhData = await getCountry(id as string, "zh");
|
const zhData = await getCountry(id as string, "zh");
|
||||||
|
|
||||||
// Set data for each language
|
|
||||||
setEditCountryData(ruData.name, "ru");
|
setEditCountryData(ruData.name, "ru");
|
||||||
setEditCountryData(enData.name, "en");
|
setEditCountryData(enData.name, "en");
|
||||||
setEditCountryData(zhData.name, "zh");
|
setEditCountryData(zhData.name, "zh");
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ export const LoginPage = () => {
|
|||||||
const { login } = authStore;
|
const { login } = authStore;
|
||||||
const { getUsers } = userStore;
|
const { getUsers } = userStore;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Load saved credentials if they exist
|
|
||||||
const savedEmail = localStorage.getItem("rememberedEmail");
|
const savedEmail = localStorage.getItem("rememberedEmail");
|
||||||
const savedPassword = localStorage.getItem("rememberedPassword");
|
const savedPassword = localStorage.getItem("rememberedPassword");
|
||||||
if (savedEmail && savedPassword) {
|
if (savedEmail && savedPassword) {
|
||||||
@@ -42,7 +41,6 @@ export const LoginPage = () => {
|
|||||||
try {
|
try {
|
||||||
await login(email, password);
|
await login(email, password);
|
||||||
|
|
||||||
// Save or clear credentials based on remember me checkbox
|
|
||||||
if (rememberMe) {
|
if (rememberMe) {
|
||||||
localStorage.setItem("rememberedEmail", email);
|
localStorage.setItem("rememberedEmail", email);
|
||||||
localStorage.setItem("rememberedPassword", password);
|
localStorage.setItem("rememberedPassword", password);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -22,10 +22,8 @@ interface ApiSight {
|
|||||||
longitude: number;
|
longitude: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Допуск для сравнения координат, чтобы избежать ошибок с точностью чисел.
|
|
||||||
const COORDINATE_PRECISION_TOLERANCE = 1e-9;
|
const COORDINATE_PRECISION_TOLERANCE = 1e-9;
|
||||||
|
|
||||||
// Вспомогательная функция, обновленная для сравнения с допуском.
|
|
||||||
const arePathsEqual = (
|
const arePathsEqual = (
|
||||||
path1: [number, number][],
|
path1: [number, number][],
|
||||||
path2: [number, number][]
|
path2: [number, number][]
|
||||||
@@ -136,7 +134,6 @@ class MapStore {
|
|||||||
longitude: geometry.coordinates[0],
|
longitude: geometry.coordinates[0],
|
||||||
};
|
};
|
||||||
|
|
||||||
// ИЗМЕНЕНИЕ: Сравнение координат с допуском
|
|
||||||
if (
|
if (
|
||||||
originalStation.name !== currentStation.name ||
|
originalStation.name !== currentStation.name ||
|
||||||
Math.abs(originalStation.latitude - currentStation.latitude) >
|
Math.abs(originalStation.latitude - currentStation.latitude) >
|
||||||
@@ -155,7 +152,6 @@ class MapStore {
|
|||||||
path: geometry.coordinates,
|
path: geometry.coordinates,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ИЗМЕНЕНИЕ: Используется новая функция arePathsEqual с допуском
|
|
||||||
if (
|
if (
|
||||||
originalRoute.route_number !== currentRoute.route_number ||
|
originalRoute.route_number !== currentRoute.route_number ||
|
||||||
!arePathsEqual(originalRoute.path, currentRoute.path)
|
!arePathsEqual(originalRoute.path, currentRoute.path)
|
||||||
@@ -173,7 +169,6 @@ class MapStore {
|
|||||||
longitude: geometry.coordinates[0],
|
longitude: geometry.coordinates[0],
|
||||||
};
|
};
|
||||||
|
|
||||||
// ИЗМЕНЕНИЕ: Сравнение координат с допуском
|
|
||||||
if (
|
if (
|
||||||
originalSight.name !== currentSight.name ||
|
originalSight.name !== currentSight.name ||
|
||||||
originalSight.description !== currentSight.description ||
|
originalSight.description !== currentSight.description ||
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export const MediaEditPage = observer(() => {
|
|||||||
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [newFile, setNewFile] = useState<File | null>(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 media = id ? mediaStore.media.find((m) => m.id === id) : null;
|
||||||
const [mediaName, setMediaName] = useState(media?.media_name ?? "");
|
const [mediaName, setMediaName] = useState(media?.media_name ?? "");
|
||||||
@@ -55,51 +55,27 @@ export const MediaEditPage = observer(() => {
|
|||||||
setMediaFilename(media.filename);
|
setMediaFilename(media.filename);
|
||||||
setMediaType(media.media_type);
|
setMediaType(media.media_type);
|
||||||
|
|
||||||
// Set available media types based on current file extension
|
|
||||||
const extension = media.filename.split(".").pop()?.toLowerCase();
|
const extension = media.filename.split(".").pop()?.toLowerCase();
|
||||||
if (extension) {
|
if (extension) {
|
||||||
if (["glb", "gltf"].includes(extension)) {
|
if (["glb", "gltf"].includes(extension)) {
|
||||||
setAvailableMediaTypes([6]); // 3D model
|
setAvailableMediaTypes([6]);
|
||||||
} else if (
|
} else if (
|
||||||
["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp"].includes(
|
["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp"].includes(
|
||||||
extension
|
extension
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
setAvailableMediaTypes([1, 3, 4, 5]); // Photo, Icon, Watermark, Panorama
|
setAvailableMediaTypes([1, 3, 4, 5]);
|
||||||
} else if (["mp4", "webm", "mov"].includes(extension)) {
|
} else if (["mp4", "webm", "mov"].includes(extension)) {
|
||||||
setAvailableMediaTypes([2]); // Video
|
setAvailableMediaTypes([2]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [media]);
|
}, [media]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Устанавливаем русский язык при загрузке страницы
|
|
||||||
languageStore.setLanguage("ru");
|
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 handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const files = e.target.files;
|
const files = e.target.files;
|
||||||
if (files && files.length > 0) {
|
if (files && files.length > 0) {
|
||||||
@@ -107,26 +83,25 @@ export const MediaEditPage = observer(() => {
|
|||||||
setNewFile(file);
|
setNewFile(file);
|
||||||
setMediaFilename(file.name);
|
setMediaFilename(file.name);
|
||||||
|
|
||||||
// Determine media type based on file extension
|
|
||||||
const extension = file.name.split(".").pop()?.toLowerCase();
|
const extension = file.name.split(".").pop()?.toLowerCase();
|
||||||
if (extension) {
|
if (extension) {
|
||||||
if (["glb", "gltf"].includes(extension)) {
|
if (["glb", "gltf"].includes(extension)) {
|
||||||
setAvailableMediaTypes([6]); // 3D model
|
setAvailableMediaTypes([6]);
|
||||||
setMediaType(6);
|
setMediaType(6);
|
||||||
} else if (
|
} else if (
|
||||||
["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp"].includes(
|
["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp"].includes(
|
||||||
extension
|
extension
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
setAvailableMediaTypes([1, 3, 4, 5]); // Photo, Icon, Watermark, Panorama
|
setAvailableMediaTypes([1, 3, 4, 5]);
|
||||||
setMediaType(1); // Default to Photo
|
setMediaType(1);
|
||||||
} else if (["mp4", "webm", "mov"].includes(extension)) {
|
} else if (["mp4", "webm", "mov"].includes(extension)) {
|
||||||
setAvailableMediaTypes([2]); // Video
|
setAvailableMediaTypes([2]);
|
||||||
setMediaType(2);
|
setMediaType(2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setUploadDialogOpen(true); // Open dialog on file selection
|
setUploadDialogOpen(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -143,11 +118,6 @@ export const MediaEditPage = observer(() => {
|
|||||||
type: mediaType,
|
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);
|
setSuccess(true);
|
||||||
handleUploadSuccess();
|
handleUploadSuccess();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -158,17 +128,15 @@ export const MediaEditPage = observer(() => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleUploadSuccess = () => {
|
const handleUploadSuccess = () => {
|
||||||
// After successful upload in the dialog, refresh media data if needed
|
|
||||||
if (id) {
|
if (id) {
|
||||||
mediaStore.getOneMedia(id);
|
mediaStore.getOneMedia(id);
|
||||||
}
|
}
|
||||||
setNewFile(null); // Clear the new file state after successful upload
|
setNewFile(null);
|
||||||
setUploadDialogOpen(false);
|
setUploadDialogOpen(false);
|
||||||
setSuccess(true);
|
setSuccess(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!media && id) {
|
if (!media && id) {
|
||||||
// Only show loading if an ID is present and media is not yet loaded
|
|
||||||
return (
|
return (
|
||||||
<Box className="flex justify-center items-center h-screen">
|
<Box className="flex justify-center items-center h-screen">
|
||||||
<CircularProgress />
|
<CircularProgress />
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ import {
|
|||||||
} from "@shared";
|
} from "@shared";
|
||||||
import { EditStationModal } from "../../widgets/modals/EditStationModal";
|
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[] {
|
function insertAtPosition<T>(arr: T[], pos: number, value: T): T[] {
|
||||||
const index = pos - 1;
|
const index = pos - 1;
|
||||||
const result = [...arr];
|
const result = [...arr];
|
||||||
@@ -54,7 +53,6 @@ function insertAtPosition<T>(arr: T[], pos: number, value: T): T[] {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to reorder items after drag and drop
|
|
||||||
const reorder = <T,>(list: T[], startIndex: number, endIndex: number): T[] => {
|
const reorder = <T,>(list: T[], startIndex: number, endIndex: number): T[] => {
|
||||||
const result = Array.from(list);
|
const result = Array.from(list);
|
||||||
const [removed] = result.splice(startIndex, 1);
|
const [removed] = result.splice(startIndex, 1);
|
||||||
@@ -152,13 +150,11 @@ const LinkedItemsContentsInner = <
|
|||||||
const availableItems = allItems
|
const availableItems = allItems
|
||||||
.filter((item) => !linkedItems.some((linked) => linked.id === item.id))
|
.filter((item) => !linkedItems.some((linked) => linked.id === item.id))
|
||||||
.filter((item) => {
|
.filter((item) => {
|
||||||
// Если направление маршрута не указано, показываем все станции
|
|
||||||
if (routeDirection === undefined) return true;
|
if (routeDirection === undefined) return true;
|
||||||
// Фильтруем станции по направлению маршрута
|
|
||||||
return item.direction === routeDirection;
|
return item.direction === routeDirection;
|
||||||
})
|
})
|
||||||
.filter((item) => {
|
.filter((item) => {
|
||||||
// Фильтруем по городу из навбара
|
|
||||||
const selectedCityId = selectedCityStore.selectedCityId;
|
const selectedCityId = selectedCityStore.selectedCityId;
|
||||||
if (selectedCityId && "city_id" in item) {
|
if (selectedCityId && "city_id" in item) {
|
||||||
return item.city_id === selectedCityId;
|
return item.city_id === selectedCityId;
|
||||||
@@ -167,7 +163,6 @@ const LinkedItemsContentsInner = <
|
|||||||
})
|
})
|
||||||
.sort((a, b) => a.name.localeCompare(b.name));
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
// Фильтрация по поиску для массового режима
|
|
||||||
const filteredAvailableItems = availableItems.filter((item) => {
|
const filteredAvailableItems = availableItems.filter((item) => {
|
||||||
if (!searchQuery.trim()) return true;
|
if (!searchQuery.trim()) return true;
|
||||||
return String(item.name).toLowerCase().includes(searchQuery.toLowerCase());
|
return String(item.name).toLowerCase().includes(searchQuery.toLowerCase());
|
||||||
@@ -562,7 +557,14 @@ const LinkedItemsContentsInner = <
|
|||||||
size="small"
|
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={{
|
sx={{
|
||||||
margin: 0,
|
margin: 0,
|
||||||
"& .MuiFormControlLabel-label": {
|
"& .MuiFormControlLabel-label": {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
DialogContent,
|
DialogContent,
|
||||||
DialogActions,
|
DialogActions,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import { MediaViewer } from "@widgets";
|
import { MediaViewer, VideoPreviewCard } from "@widgets";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { ArrowLeft, Loader2, Save, Plus } from "lucide-react";
|
import { ArrowLeft, Loader2, Save, Plus } from "lucide-react";
|
||||||
import { useEffect, useState, useMemo } from "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 { Route, routeStore } from "../../../shared/store/RouteStore";
|
||||||
import {
|
import {
|
||||||
languageStore,
|
languageStore,
|
||||||
SelectArticleModal,
|
ArticleSelectOrCreateDialog,
|
||||||
SelectMediaDialog,
|
SelectMediaDialog,
|
||||||
selectedCityStore,
|
selectedCityStore,
|
||||||
|
UploadMediaDialog,
|
||||||
} from "@shared";
|
} from "@shared";
|
||||||
|
|
||||||
export const RouteCreatePage = observer(() => {
|
export const RouteCreatePage = observer(() => {
|
||||||
@@ -37,8 +38,9 @@ export const RouteCreatePage = observer(() => {
|
|||||||
const [govRouteNumber, setGovRouteNumber] = useState("");
|
const [govRouteNumber, setGovRouteNumber] = useState("");
|
||||||
const [governorAppeal, setGovernorAppeal] = useState<string>("");
|
const [governorAppeal, setGovernorAppeal] = useState<string>("");
|
||||||
const [direction, setDirection] = useState("backward");
|
const [direction, setDirection] = useState("backward");
|
||||||
const [scaleMin, setScaleMin] = useState("");
|
const [scaleMin, setScaleMin] = useState("10");
|
||||||
const [scaleMax, setScaleMax] = useState("");
|
const [scaleMax, setScaleMax] = useState("100");
|
||||||
|
const [routeName, setRouteName] = useState("");
|
||||||
const [turn, setTurn] = useState("");
|
const [turn, setTurn] = useState("");
|
||||||
const [centerLat, setCenterLat] = useState("");
|
const [centerLat, setCenterLat] = useState("");
|
||||||
const [centerLng, setCenterLng] = useState("");
|
const [centerLng, setCenterLng] = useState("");
|
||||||
@@ -48,6 +50,8 @@ export const RouteCreatePage = observer(() => {
|
|||||||
useState(false);
|
useState(false);
|
||||||
const [isSelectVideoDialogOpen, setIsSelectVideoDialogOpen] = useState(false);
|
const [isSelectVideoDialogOpen, setIsSelectVideoDialogOpen] = useState(false);
|
||||||
const [isVideoPreviewOpen, setIsVideoPreviewOpen] = useState(false);
|
const [isVideoPreviewOpen, setIsVideoPreviewOpen] = useState(false);
|
||||||
|
const [isUploadVideoDialogOpen, setIsUploadVideoDialogOpen] = useState(false);
|
||||||
|
const [fileToUpload, setFileToUpload] = useState<File | null>(null);
|
||||||
const { language } = languageStore;
|
const { language } = languageStore;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -55,7 +59,6 @@ export const RouteCreatePage = observer(() => {
|
|||||||
articlesStore.getArticleList();
|
articlesStore.getArticleList();
|
||||||
}, [language]);
|
}, [language]);
|
||||||
|
|
||||||
// Фильтруем перевозчиков только из выбранного города
|
|
||||||
const filteredCarriers = useMemo(() => {
|
const filteredCarriers = useMemo(() => {
|
||||||
const carriers =
|
const carriers =
|
||||||
carrierStore.carriers[language as keyof typeof carrierStore.carriers]
|
carrierStore.carriers[language as keyof typeof carrierStore.carriers]
|
||||||
@@ -110,6 +113,7 @@ export const RouteCreatePage = observer(() => {
|
|||||||
const handleArticleSelect = (articleId: number) => {
|
const handleArticleSelect = (articleId: number) => {
|
||||||
setGovernorAppeal(articleId.toString());
|
setGovernorAppeal(articleId.toString());
|
||||||
setIsSelectArticleDialogOpen(false);
|
setIsSelectArticleDialogOpen(false);
|
||||||
|
articlesStore.getArticleList();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleVideoSelect = (media: {
|
const handleVideoSelect = (media: {
|
||||||
@@ -122,6 +126,26 @@ export const RouteCreatePage = observer(() => {
|
|||||||
setIsSelectVideoDialogOpen(false);
|
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 = () => {
|
const handleVideoPreviewClick = () => {
|
||||||
setIsVideoPreviewOpen(true);
|
setIsVideoPreviewOpen(true);
|
||||||
};
|
};
|
||||||
@@ -129,23 +153,72 @@ export const RouteCreatePage = observer(() => {
|
|||||||
const handleCreateRoute = async () => {
|
const handleCreateRoute = async () => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
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 carrier_id = Number(carrier);
|
||||||
const governor_appeal = Number(governorAppeal);
|
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 rotate = turn ? Number(turn) : undefined;
|
||||||
const center_latitude = centerLat ? Number(centerLat) : undefined;
|
const center_latitude = centerLat ? Number(centerLat) : undefined;
|
||||||
const center_longitude = centerLng ? Number(centerLng) : undefined;
|
const center_longitude = centerLng ? Number(centerLng) : undefined;
|
||||||
const route_direction = direction === "forward";
|
const route_direction = direction === "forward";
|
||||||
|
|
||||||
const validationResult = validateCoordinates(routeCoords);
|
|
||||||
if (validationResult !== true) {
|
|
||||||
toast.error(validationResult);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Координаты маршрута как массив массивов чисел
|
|
||||||
const path = routeCoords
|
const path = routeCoords
|
||||||
.trim()
|
.trim()
|
||||||
.split("\n")
|
.split("\n")
|
||||||
@@ -157,7 +230,6 @@ export const RouteCreatePage = observer(() => {
|
|||||||
return [lat, lon];
|
return [lat, lon];
|
||||||
});
|
});
|
||||||
|
|
||||||
// Собираем объект маршрута
|
|
||||||
const newRoute: Partial<Route> = {
|
const newRoute: Partial<Route> = {
|
||||||
carrier:
|
carrier:
|
||||||
carrierStore.carriers[
|
carrierStore.carriers[
|
||||||
@@ -167,9 +239,10 @@ export const RouteCreatePage = observer(() => {
|
|||||||
route_number: routeNumber,
|
route_number: routeNumber,
|
||||||
route_sys_number: govRouteNumber,
|
route_sys_number: govRouteNumber,
|
||||||
governor_appeal,
|
governor_appeal,
|
||||||
|
route_name: routeName,
|
||||||
route_direction,
|
route_direction,
|
||||||
scale_min,
|
scale_min: scale_min !== null ? scale_min : 0,
|
||||||
scale_max,
|
scale_max: scale_max !== null ? scale_max : 0,
|
||||||
rotate,
|
rotate,
|
||||||
center_latitude,
|
center_latitude,
|
||||||
center_longitude,
|
center_longitude,
|
||||||
@@ -189,7 +262,6 @@ export const RouteCreatePage = observer(() => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Получаем название выбранной статьи для отображения
|
|
||||||
const selectedArticle = articlesStore.articleList.ru.data.find(
|
const selectedArticle = articlesStore.articleList.ru.data.find(
|
||||||
(article) => article.id === Number(governorAppeal)
|
(article) => article.id === Number(governorAppeal)
|
||||||
);
|
);
|
||||||
@@ -208,6 +280,13 @@ export const RouteCreatePage = observer(() => {
|
|||||||
|
|
||||||
<div className="flex flex-col gap-10 w-full items-end">
|
<div className="flex flex-col gap-10 w-full items-end">
|
||||||
<Box className="flex flex-col gap-6 w-full">
|
<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>
|
<FormControl fullWidth required>
|
||||||
<InputLabel>Выберите перевозчика</InputLabel>
|
<InputLabel>Выберите перевозчика</InputLabel>
|
||||||
<Select
|
<Select
|
||||||
@@ -247,7 +326,6 @@ export const RouteCreatePage = observer(() => {
|
|||||||
const lines = routeCoords.split("\n");
|
const lines = routeCoords.split("\n");
|
||||||
const lastLine = lines[lines.length - 1];
|
const lastLine = lines[lines.length - 1];
|
||||||
|
|
||||||
// Если мы на последней строке и она не пустая
|
|
||||||
if (lastLine && lastLine.trim()) {
|
if (lastLine && lastLine.trim()) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const newValue = routeCoords + "\n";
|
const newValue = routeCoords + "\n";
|
||||||
@@ -279,6 +357,7 @@ export const RouteCreatePage = observer(() => {
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
className="w-full"
|
className="w-full"
|
||||||
label="Номер маршрута в Говорящем Городе"
|
label="Номер маршрута в Говорящем Городе"
|
||||||
@@ -287,99 +366,42 @@ export const RouteCreatePage = observer(() => {
|
|||||||
onChange={(e) => setGovRouteNumber(e.target.value)}
|
onChange={(e) => setGovRouteNumber(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Заменяем Select на кнопку для выбора статьи */}
|
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
||||||
<Box className="flex flex-col gap-2">
|
Обращение к пассажирам
|
||||||
<label className="text-sm font-medium text-gray-700">
|
</Typography>
|
||||||
Обращение к пассажирам
|
<Box className="flex gap-2">
|
||||||
</label>
|
<TextField
|
||||||
<Box className="flex gap-2">
|
className="flex-1"
|
||||||
<TextField
|
value={selectedArticle?.heading || "Статья не выбрана"}
|
||||||
className="flex-1"
|
placeholder="Выберите статью"
|
||||||
value={selectedArticle?.heading || "Статья не выбрана"}
|
disabled
|
||||||
placeholder="Выберите статью"
|
fullWidth
|
||||||
disabled
|
sx={{
|
||||||
sx={{
|
"& .MuiInputBase-input": {
|
||||||
"& .MuiInputBase-input": {
|
color: selectedArticle ? "inherit" : "#999",
|
||||||
color: selectedArticle ? "inherit" : "#999",
|
},
|
||||||
},
|
}}
|
||||||
}}
|
/>
|
||||||
/>
|
<Button
|
||||||
<Button
|
variant="outlined"
|
||||||
variant="outlined"
|
onClick={() => setIsSelectArticleDialogOpen(true)}
|
||||||
onClick={() => setIsSelectArticleDialogOpen(true)}
|
startIcon={<Plus size={16} />}
|
||||||
startIcon={<Plus size={16} />}
|
sx={{ minWidth: "auto", px: 2 }}
|
||||||
sx={{ minWidth: "auto", px: 2 }}
|
>
|
||||||
>
|
Выбрать
|
||||||
Выбрать
|
</Button>
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Селектор видеозаставки */}
|
<VideoPreviewCard
|
||||||
<Box className="flex flex-col gap-2">
|
title="Видеозаставка"
|
||||||
<label className="text-sm font-medium text-gray-700">
|
videoId={videoPreview}
|
||||||
Видеозаставка
|
onVideoClick={handleVideoPreviewClick}
|
||||||
</label>
|
onDeleteVideoClick={() => {
|
||||||
<Box className="flex gap-2">
|
setVideoPreview("");
|
||||||
<Box
|
}}
|
||||||
className="flex-1"
|
onSelectVideoClick={handleVideoFileSelect}
|
||||||
onClick={handleVideoPreviewClick}
|
className="w-full"
|
||||||
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>
|
|
||||||
|
|
||||||
<FormControl fullWidth required>
|
<FormControl fullWidth required>
|
||||||
<InputLabel>Прямой/обратный маршрут</InputLabel>
|
<InputLabel>Прямой/обратный маршрут</InputLabel>
|
||||||
@@ -395,15 +417,41 @@ export const RouteCreatePage = observer(() => {
|
|||||||
<TextField
|
<TextField
|
||||||
className="w-full"
|
className="w-full"
|
||||||
label="Масштаб (мин)"
|
label="Масштаб (мин)"
|
||||||
|
type="number"
|
||||||
value={scaleMin}
|
value={scaleMin}
|
||||||
onChange={(e) => setScaleMin(e.target.value)}
|
onChange={(e) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
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
|
<TextField
|
||||||
className="w-full"
|
className="w-full"
|
||||||
label="Масштаб (макс)"
|
label="Масштаб (макс)"
|
||||||
|
type="number"
|
||||||
value={scaleMax}
|
value={scaleMax}
|
||||||
onChange={(e) => setScaleMax(e.target.value)}
|
required
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
setScaleMax(value);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
className="w-full"
|
className="w-full"
|
||||||
label="Поворот"
|
label="Поворот"
|
||||||
@@ -440,23 +488,17 @@ export const RouteCreatePage = observer(() => {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<ArticleSelectOrCreateDialog
|
||||||
{/* Модальное окно выбора статьи */}
|
|
||||||
<SelectArticleModal
|
|
||||||
open={isSelectArticleDialogOpen}
|
open={isSelectArticleDialogOpen}
|
||||||
onClose={() => setIsSelectArticleDialogOpen(false)}
|
onClose={() => setIsSelectArticleDialogOpen(false)}
|
||||||
onSelectArticle={handleArticleSelect}
|
onSelectArticle={handleArticleSelect}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Модальное окно выбора видео */}
|
|
||||||
<SelectMediaDialog
|
<SelectMediaDialog
|
||||||
open={isSelectVideoDialogOpen}
|
open={isSelectVideoDialogOpen}
|
||||||
onClose={() => setIsSelectVideoDialogOpen(false)}
|
onClose={() => setIsSelectVideoDialogOpen(false)}
|
||||||
onSelectMedia={handleVideoSelect}
|
onSelectMedia={handleVideoSelect}
|
||||||
mediaType={2}
|
mediaType={2}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Модальное окно предпросмотра видео */}
|
|
||||||
{videoPreview && videoPreview !== "" && (
|
{videoPreview && videoPreview !== "" && (
|
||||||
<Dialog
|
<Dialog
|
||||||
open={isVideoPreviewOpen}
|
open={isVideoPreviewOpen}
|
||||||
@@ -483,6 +525,18 @@ export const RouteCreatePage = observer(() => {
|
|||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)}
|
)}
|
||||||
|
<UploadMediaDialog
|
||||||
|
open={isUploadVideoDialogOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setIsUploadVideoDialogOpen(false);
|
||||||
|
setFileToUpload(null);
|
||||||
|
}}
|
||||||
|
hardcodeType="video_preview"
|
||||||
|
contextObjectName={routeName || "Маршрут"}
|
||||||
|
contextType="sight"
|
||||||
|
initialFile={fileToUpload || undefined}
|
||||||
|
afterUpload={handleVideoUpload}
|
||||||
|
/>
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
DialogContent,
|
DialogContent,
|
||||||
DialogActions,
|
DialogActions,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import { MediaViewer } from "@widgets";
|
import { MediaViewer, VideoPreviewCard } from "@widgets";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { ArrowLeft, Copy, Save, Plus } from "lucide-react";
|
import { ArrowLeft, Copy, Save, Plus } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
@@ -24,8 +24,9 @@ import { articlesStore } from "../../../shared/store/ArticlesStore";
|
|||||||
import {
|
import {
|
||||||
routeStore,
|
routeStore,
|
||||||
languageStore,
|
languageStore,
|
||||||
SelectArticleModal,
|
ArticleSelectOrCreateDialog,
|
||||||
SelectMediaDialog,
|
SelectMediaDialog,
|
||||||
|
UploadMediaDialog,
|
||||||
} from "@shared";
|
} from "@shared";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import { stationsStore } from "@shared";
|
import { stationsStore } from "@shared";
|
||||||
@@ -40,12 +41,13 @@ export const RouteEditPage = observer(() => {
|
|||||||
useState(false);
|
useState(false);
|
||||||
const [isSelectVideoDialogOpen, setIsSelectVideoDialogOpen] = useState(false);
|
const [isSelectVideoDialogOpen, setIsSelectVideoDialogOpen] = useState(false);
|
||||||
const [isVideoPreviewOpen, setIsVideoPreviewOpen] = useState(false);
|
const [isVideoPreviewOpen, setIsVideoPreviewOpen] = useState(false);
|
||||||
|
const [isUploadVideoDialogOpen, setIsUploadVideoDialogOpen] = useState(false);
|
||||||
|
const [fileToUpload, setFileToUpload] = useState<File | null>(null);
|
||||||
const { language } = languageStore;
|
const { language } = languageStore;
|
||||||
const [coordinates, setCoordinates] = useState<string>("");
|
const [coordinates, setCoordinates] = useState<string>("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
// Устанавливаем русский язык при загрузке страницы
|
|
||||||
const response = await routeStore.getRoute(Number(id));
|
const response = await routeStore.getRoute(Number(id));
|
||||||
routeStore.setEditRouteData(response);
|
routeStore.setEditRouteData(response);
|
||||||
languageStore.setLanguage("ru");
|
languageStore.setLanguage("ru");
|
||||||
@@ -72,10 +74,67 @@ export const RouteEditPage = observer(() => {
|
|||||||
}, [editRouteData.path]);
|
}, [editRouteData.path]);
|
||||||
|
|
||||||
const handleSave = async () => {
|
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);
|
setIsLoading(true);
|
||||||
await routeStore.editRoute(Number(id));
|
try {
|
||||||
toast.success("Маршрут успешно сохранен");
|
await routeStore.editRoute(Number(id));
|
||||||
setIsLoading(false);
|
toast.success("Маршрут успешно сохранен");
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
toast.error("Произошла ошибка при сохранении маршрута");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const validateCoordinates = (value: string) => {
|
const validateCoordinates = (value: string) => {
|
||||||
@@ -125,6 +184,8 @@ export const RouteEditPage = observer(() => {
|
|||||||
governor_appeal: articleId,
|
governor_appeal: articleId,
|
||||||
});
|
});
|
||||||
setIsSelectArticleDialogOpen(false);
|
setIsSelectArticleDialogOpen(false);
|
||||||
|
// Обновляем список статей после создания новой
|
||||||
|
articlesStore.getArticleList();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleVideoSelect = (media: {
|
const handleVideoSelect = (media: {
|
||||||
@@ -139,6 +200,28 @@ export const RouteEditPage = observer(() => {
|
|||||||
setIsSelectVideoDialogOpen(false);
|
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 = () => {
|
const handleVideoPreviewClick = () => {
|
||||||
if (editRouteData.video_preview && editRouteData.video_preview !== "") {
|
if (editRouteData.video_preview && editRouteData.video_preview !== "") {
|
||||||
setIsVideoPreviewOpen(true);
|
setIsVideoPreviewOpen(true);
|
||||||
@@ -164,6 +247,17 @@ export const RouteEditPage = observer(() => {
|
|||||||
|
|
||||||
<div className="flex flex-col gap-10 w-full items-end">
|
<div className="flex flex-col gap-10 w-full items-end">
|
||||||
<Box className="flex flex-col gap-6 w-full">
|
<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>
|
<FormControl fullWidth required>
|
||||||
<InputLabel>Выберите перевозчика</InputLabel>
|
<InputLabel>Выберите перевозчика</InputLabel>
|
||||||
<Select
|
<Select
|
||||||
@@ -235,7 +329,6 @@ export const RouteEditPage = observer(() => {
|
|||||||
const lines = coordinates.split("\n");
|
const lines = coordinates.split("\n");
|
||||||
const lastLine = lines[lines.length - 1];
|
const lastLine = lines[lines.length - 1];
|
||||||
|
|
||||||
// Если мы на последней строке и она не пустая
|
|
||||||
if (lastLine && lastLine.trim()) {
|
if (lastLine && lastLine.trim()) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const newValue = coordinates + "\n";
|
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>
|
<FormControl fullWidth required>
|
||||||
<InputLabel>Прямой/обратный маршрут</InputLabel>
|
<InputLabel>Прямой/обратный маршрут</InputLabel>
|
||||||
<Select
|
<Select
|
||||||
@@ -401,17 +390,33 @@ export const RouteEditPage = observer(() => {
|
|||||||
<TextField
|
<TextField
|
||||||
className="w-full"
|
className="w-full"
|
||||||
label="Масштаб (мин)"
|
label="Масштаб (мин)"
|
||||||
|
type="number"
|
||||||
value={editRouteData.scale_min ?? ""}
|
value={editRouteData.scale_min ?? ""}
|
||||||
onChange={(e) =>
|
onChange={(e) => {
|
||||||
|
const value =
|
||||||
|
e.target.value === "" ? null : parseFloat(e.target.value);
|
||||||
routeStore.setEditRouteData({
|
routeStore.setEditRouteData({
|
||||||
scale_min:
|
scale_min: value,
|
||||||
e.target.value === "" ? null : parseFloat(e.target.value),
|
});
|
||||||
})
|
// Если максимальный масштаб стал меньше минимального, обновляем его
|
||||||
}
|
if (
|
||||||
|
value !== null &&
|
||||||
|
editRouteData.scale_max !== null &&
|
||||||
|
editRouteData.scale_max !== undefined &&
|
||||||
|
value > editRouteData.scale_max
|
||||||
|
) {
|
||||||
|
routeStore.setEditRouteData({
|
||||||
|
scale_max: value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
className="w-full"
|
className="w-full"
|
||||||
|
required
|
||||||
label="Масштаб (макс)"
|
label="Масштаб (макс)"
|
||||||
|
type="number"
|
||||||
value={editRouteData.scale_max ?? ""}
|
value={editRouteData.scale_max ?? ""}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
routeStore.setEditRouteData({
|
routeStore.setEditRouteData({
|
||||||
@@ -419,6 +424,22 @@ export const RouteEditPage = observer(() => {
|
|||||||
e.target.value === "" ? null : parseFloat(e.target.value),
|
e.target.value === "" ? null : parseFloat(e.target.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
|
<TextField
|
||||||
className="w-full"
|
className="w-full"
|
||||||
@@ -453,6 +474,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>
|
</Box>
|
||||||
|
|
||||||
<LinkedItems
|
<LinkedItems
|
||||||
@@ -493,23 +551,17 @@ export const RouteEditPage = observer(() => {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<ArticleSelectOrCreateDialog
|
||||||
{/* Модальное окно выбора статьи */}
|
|
||||||
<SelectArticleModal
|
|
||||||
open={isSelectArticleDialogOpen}
|
open={isSelectArticleDialogOpen}
|
||||||
onClose={() => setIsSelectArticleDialogOpen(false)}
|
onClose={() => setIsSelectArticleDialogOpen(false)}
|
||||||
onSelectArticle={handleArticleSelect}
|
onSelectArticle={handleArticleSelect}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Модальное окно выбора видео */}
|
|
||||||
<SelectMediaDialog
|
<SelectMediaDialog
|
||||||
open={isSelectVideoDialogOpen}
|
open={isSelectVideoDialogOpen}
|
||||||
onClose={() => setIsSelectVideoDialogOpen(false)}
|
onClose={() => setIsSelectVideoDialogOpen(false)}
|
||||||
onSelectMedia={handleVideoSelect}
|
onSelectMedia={handleVideoSelect}
|
||||||
mediaType={2}
|
mediaType={2}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Модальное окно предпросмотра видео */}
|
|
||||||
<Dialog
|
<Dialog
|
||||||
open={isVideoPreviewOpen}
|
open={isVideoPreviewOpen}
|
||||||
onClose={() => setIsVideoPreviewOpen(false)}
|
onClose={() => setIsVideoPreviewOpen(false)}
|
||||||
@@ -519,19 +571,33 @@ export const RouteEditPage = observer(() => {
|
|||||||
<DialogTitle>Предпросмотр видео</DialogTitle>
|
<DialogTitle>Предпросмотр видео</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<Box className="flex justify-center items-center p-4">
|
<Box className="flex justify-center items-center p-4">
|
||||||
<MediaViewer
|
{editRouteData.video_preview && (
|
||||||
media={{
|
<MediaViewer
|
||||||
id: editRouteData.video_preview,
|
media={{
|
||||||
media_type: 2,
|
id: editRouteData.video_preview,
|
||||||
filename: "video_preview",
|
media_type: 2,
|
||||||
}}
|
filename: "video_preview",
|
||||||
/>
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<Button onClick={() => setIsVideoPreviewOpen(false)}>Закрыть</Button>
|
<Button onClick={() => setIsVideoPreviewOpen(false)}>Закрыть</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
<UploadMediaDialog
|
||||||
|
open={isUploadVideoDialogOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setIsUploadVideoDialogOpen(false);
|
||||||
|
setFileToUpload(null);
|
||||||
|
}}
|
||||||
|
hardcodeType="video_preview"
|
||||||
|
contextObjectName={editRouteData.route_name || "Маршрут"}
|
||||||
|
contextType="sight"
|
||||||
|
initialFile={fileToUpload || undefined}
|
||||||
|
afterUpload={handleVideoUpload}
|
||||||
|
/>
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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",
|
field: "route_number",
|
||||||
headerName: "Номер маршрута",
|
headerName: "Номер маршрута",
|
||||||
@@ -100,9 +116,7 @@ export const RouteListPage = observer(() => {
|
|||||||
<button onClick={() => navigate(`/route-preview/${params.row.id}`)}>
|
<button onClick={() => navigate(`/route-preview/${params.row.id}`)}>
|
||||||
<Map size={20} className="text-purple-500" />
|
<Map size={20} className="text-purple-500" />
|
||||||
</button>
|
</button>
|
||||||
{/* <button onClick={() => navigate(`/route/${params.row.id}`)}>
|
|
||||||
<Eye size={20} className="text-green-500" />
|
|
||||||
</button> */}
|
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsDeleteModalOpen(true);
|
setIsDeleteModalOpen(true);
|
||||||
@@ -122,6 +136,7 @@ export const RouteListPage = observer(() => {
|
|||||||
carrier_id: route.carrier_id,
|
carrier_id: route.carrier_id,
|
||||||
route_number: route.route_number,
|
route_number: route.route_number,
|
||||||
route_direction: route.route_direction ? "Прямой" : "Обратный",
|
route_direction: route.route_direction ? "Прямой" : "Обратный",
|
||||||
|
route_name: route.route_name,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -47,10 +47,8 @@ export function InfiniteCanvas({
|
|||||||
const [startPosition, setStartPosition] = useState({ x: 0, y: 0 });
|
const [startPosition, setStartPosition] = useState({ x: 0, y: 0 });
|
||||||
const [isPointerDown, setIsPointerDown] = useState(false);
|
const [isPointerDown, setIsPointerDown] = useState(false);
|
||||||
|
|
||||||
// Флаг для предотвращения конфликта между пользовательским вводом и данными маршрута
|
|
||||||
const [isUserInteracting, setIsUserInteracting] = useState(false);
|
const [isUserInteracting, setIsUserInteracting] = useState(false);
|
||||||
|
|
||||||
// Реф для отслеживания последнего значения originalRouteData?.rotate
|
|
||||||
const lastOriginalRotation = useRef<number | undefined>(undefined);
|
const lastOriginalRotation = useRef<number | undefined>(undefined);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -68,7 +66,7 @@ export function InfiniteCanvas({
|
|||||||
const handlePointerDown = (e: FederatedMouseEvent) => {
|
const handlePointerDown = (e: FederatedMouseEvent) => {
|
||||||
setIsPointerDown(true);
|
setIsPointerDown(true);
|
||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
setIsUserInteracting(true); // Устанавливаем флаг взаимодействия пользователя
|
setIsUserInteracting(true);
|
||||||
setStartPosition({
|
setStartPosition({
|
||||||
x: position.x,
|
x: position.x,
|
||||||
y: position.y,
|
y: position.y,
|
||||||
@@ -81,13 +79,9 @@ export function InfiniteCanvas({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Устанавливаем rotation только при изменении originalRouteData и отсутствии взаимодействия пользователя
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const newRotation = originalRouteData?.rotate ?? 0;
|
const newRotation = originalRouteData?.rotate ?? 0;
|
||||||
|
|
||||||
// Обновляем rotation только если:
|
|
||||||
// 1. Пользователь не взаимодействует с канвасом
|
|
||||||
// 2. Значение действительно изменилось
|
|
||||||
if (!isUserInteracting && lastOriginalRotation.current !== newRotation) {
|
if (!isUserInteracting && lastOriginalRotation.current !== newRotation) {
|
||||||
setRotation((newRotation * Math.PI) / 180);
|
setRotation((newRotation * Math.PI) / 180);
|
||||||
lastOriginalRotation.current = newRotation;
|
lastOriginalRotation.current = newRotation;
|
||||||
@@ -97,7 +91,6 @@ export function InfiniteCanvas({
|
|||||||
const handlePointerMove = (e: FederatedMouseEvent) => {
|
const handlePointerMove = (e: FederatedMouseEvent) => {
|
||||||
if (!isPointerDown) return;
|
if (!isPointerDown) return;
|
||||||
|
|
||||||
// Проверяем, началось ли перетаскивание
|
|
||||||
if (!isDragging) {
|
if (!isDragging) {
|
||||||
const dx = e.globalX - startMousePosition.x;
|
const dx = e.globalX - startMousePosition.x;
|
||||||
const dy = e.globalY - startMousePosition.y;
|
const dy = e.globalY - startMousePosition.y;
|
||||||
@@ -119,10 +112,8 @@ export function InfiniteCanvas({
|
|||||||
e.globalX - center.x
|
e.globalX - center.x
|
||||||
);
|
);
|
||||||
|
|
||||||
// Calculate rotation difference in radians
|
|
||||||
const rotationDiff = currentAngle - startAngle;
|
const rotationDiff = currentAngle - startAngle;
|
||||||
|
|
||||||
// Update rotation
|
|
||||||
setRotation(startRotation + rotationDiff);
|
setRotation(startRotation + rotationDiff);
|
||||||
|
|
||||||
const cosDelta = Math.cos(rotationDiff);
|
const cosDelta = Math.cos(rotationDiff);
|
||||||
@@ -149,15 +140,13 @@ export function InfiniteCanvas({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handlePointerUp = (e: FederatedMouseEvent) => {
|
const handlePointerUp = (e: FederatedMouseEvent) => {
|
||||||
// Если не было перетаскивания, то это простой клик - закрываем виджет
|
|
||||||
if (!isDragging) {
|
if (!isDragging) {
|
||||||
setSelectedSight(undefined);
|
setSelectedSight(undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsPointerDown(false);
|
setIsPointerDown(false);
|
||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
// Сбрасываем флаг взаимодействия через небольшую задержку
|
|
||||||
// чтобы избежать немедленного срабатывания useEffect
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setIsUserInteracting(false);
|
setIsUserInteracting(false);
|
||||||
}, 100);
|
}, 100);
|
||||||
@@ -166,29 +155,25 @@ export function InfiniteCanvas({
|
|||||||
|
|
||||||
const handleWheel = (e: FederatedWheelEvent) => {
|
const handleWheel = (e: FederatedWheelEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setIsUserInteracting(true); // Устанавливаем флаг при зуме
|
setIsUserInteracting(true);
|
||||||
|
|
||||||
// Get mouse position relative to canvas
|
|
||||||
const mouseX = e.globalX - position.x;
|
const mouseX = e.globalX - position.x;
|
||||||
const mouseY = e.globalY - position.y;
|
const mouseY = e.globalY - position.y;
|
||||||
|
|
||||||
// Calculate new scale
|
|
||||||
const scaleMin = (routeData?.scale_min ?? 10) / SCALE_FACTOR;
|
const scaleMin = (routeData?.scale_min ?? 10) / SCALE_FACTOR;
|
||||||
const scaleMax = (routeData?.scale_max ?? 20) / 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 newScale = Math.max(scaleMin, Math.min(scaleMax, scale * zoomFactor));
|
||||||
const actualZoomFactor = newScale / scale;
|
const actualZoomFactor = newScale / scale;
|
||||||
|
|
||||||
if (scale === newScale) {
|
if (scale === newScale) {
|
||||||
// Сбрасываем флаг, если зум не изменился
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setIsUserInteracting(false);
|
setIsUserInteracting(false);
|
||||||
}, 100);
|
}, 100);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update position to zoom towards mouse cursor
|
|
||||||
setPosition({
|
setPosition({
|
||||||
x: position.x + mouseX * (1 - actualZoomFactor),
|
x: position.x + mouseX * (1 - actualZoomFactor),
|
||||||
y: position.y + mouseY * (1 - actualZoomFactor),
|
y: position.y + mouseY * (1 - actualZoomFactor),
|
||||||
@@ -196,7 +181,6 @@ export function InfiniteCanvas({
|
|||||||
|
|
||||||
setScale(newScale);
|
setScale(newScale);
|
||||||
|
|
||||||
// Сбрасываем флаг взаимодействия через задержку
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setIsUserInteracting(false);
|
setIsUserInteracting(false);
|
||||||
}, 100);
|
}, 100);
|
||||||
|
|||||||
@@ -141,7 +141,6 @@ export const MapDataProvider = observer(
|
|||||||
}, [routeId]);
|
}, [routeId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// combine changes with original data
|
|
||||||
if (originalRouteData)
|
if (originalRouteData)
|
||||||
setRouteData({ ...originalRouteData, ...routeChanges });
|
setRouteData({ ...originalRouteData, ...routeChanges });
|
||||||
if (originalSightData) setSightData(originalSightData);
|
if (originalSightData) setSightData(originalSightData);
|
||||||
|
|||||||
@@ -37,11 +37,9 @@ export function RightSidebar() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (originalRouteData) {
|
if (originalRouteData) {
|
||||||
// Проверяем и сбрасываем минимальный масштаб если нужно
|
|
||||||
const originalMinScale = originalRouteData.scale_min ?? 1;
|
const originalMinScale = originalRouteData.scale_min ?? 1;
|
||||||
const resetMinScale = originalMinScale < 1 ? 1 : originalMinScale;
|
const resetMinScale = originalMinScale < 1 ? 1 : originalMinScale;
|
||||||
|
|
||||||
// Проверяем и сбрасываем максимальный масштаб если нужно
|
|
||||||
const originalMaxScale = originalRouteData.scale_max ?? 5;
|
const originalMaxScale = originalRouteData.scale_max ?? 5;
|
||||||
const resetMaxScale = originalMaxScale < 3 ? 3 : originalMaxScale;
|
const resetMaxScale = originalMaxScale < 3 ? 3 : originalMaxScale;
|
||||||
|
|
||||||
@@ -118,7 +116,7 @@ export function RightSidebar() {
|
|||||||
borderRadius={2}
|
borderRadius={2}
|
||||||
>
|
>
|
||||||
<Typography variant="h6" sx={{ mb: 2, color: "#fff" }} textAlign="center">
|
<Typography variant="h6" sx={{ mb: 2, color: "#fff" }} textAlign="center">
|
||||||
Детали о достопримечательностях
|
Настройка маршрута
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Stack spacing={2} direction="row" alignItems="center">
|
<Stack spacing={2} direction="row" alignItems="center">
|
||||||
@@ -130,7 +128,6 @@ export function RightSidebar() {
|
|||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
let newMinScale = Number(e.target.value);
|
let newMinScale = Number(e.target.value);
|
||||||
|
|
||||||
// Сбрасываем к 1 если меньше
|
|
||||||
if (newMinScale < 1) {
|
if (newMinScale < 1) {
|
||||||
newMinScale = 1;
|
newMinScale = 1;
|
||||||
}
|
}
|
||||||
@@ -139,10 +136,10 @@ export function RightSidebar() {
|
|||||||
|
|
||||||
if (maxScale - newMinScale < 2) {
|
if (maxScale - newMinScale < 2) {
|
||||||
let newMaxScale = newMinScale + 2;
|
let newMaxScale = newMinScale + 2;
|
||||||
// Сбрасываем максимальный к 3 если меньше минимального
|
|
||||||
if (newMaxScale < 3) {
|
if (newMaxScale < 3) {
|
||||||
newMaxScale = 3;
|
newMaxScale = 3;
|
||||||
setMinScale(1); // Сбрасываем минимальный к 1
|
setMinScale(1);
|
||||||
}
|
}
|
||||||
setMaxScale(newMaxScale);
|
setMaxScale(newMaxScale);
|
||||||
}
|
}
|
||||||
@@ -175,7 +172,6 @@ export function RightSidebar() {
|
|||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
let newMaxScale = Number(e.target.value);
|
let newMaxScale = Number(e.target.value);
|
||||||
|
|
||||||
// Сбрасываем к 3 если меньше минимального
|
|
||||||
if (newMaxScale < 3) {
|
if (newMaxScale < 3) {
|
||||||
newMaxScale = 3;
|
newMaxScale = 3;
|
||||||
}
|
}
|
||||||
@@ -184,10 +180,10 @@ export function RightSidebar() {
|
|||||||
|
|
||||||
if (newMaxScale - minScale < 2) {
|
if (newMaxScale - minScale < 2) {
|
||||||
let newMinScale = newMaxScale - 2;
|
let newMinScale = newMaxScale - 2;
|
||||||
// Сбрасываем минимальный к 1 если меньше
|
|
||||||
if (newMinScale < 1) {
|
if (newMinScale < 1) {
|
||||||
newMinScale = 1;
|
newMinScale = 1;
|
||||||
setMaxScale(3); // Сбрасываем максимальный к минимальному значению
|
setMaxScale(3);
|
||||||
}
|
}
|
||||||
setMinScale(newMinScale);
|
setMinScale(newMinScale);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { FederatedMouseEvent, Graphics } from "pixi.js";
|
|||||||
import { useCallback, useState, useEffect, useRef, FC, useMemo } from "react";
|
import { useCallback, useState, useEffect, useRef, FC, useMemo } from "react";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
|
|
||||||
// --- Заглушки для зависимостей (замените на ваши реальные импорты) ---
|
|
||||||
import {
|
import {
|
||||||
BACKGROUND_COLOR,
|
BACKGROUND_COLOR,
|
||||||
PATH_COLOR,
|
PATH_COLOR,
|
||||||
@@ -15,22 +14,16 @@ import { StationData } from "./types";
|
|||||||
import { useMapData } from "./MapDataContext";
|
import { useMapData } from "./MapDataContext";
|
||||||
import { coordinatesToLocal } from "./utils";
|
import { coordinatesToLocal } from "./utils";
|
||||||
import { languageStore } from "@shared";
|
import { languageStore } from "@shared";
|
||||||
// --- Конец заглушек ---
|
|
||||||
|
|
||||||
// --- Декларации для react-pixi ---
|
|
||||||
// (В реальном проекте типы должны быть предоставлены библиотекой @pixi/react-pixi)
|
|
||||||
declare const pixiContainer: any;
|
declare const pixiContainer: any;
|
||||||
declare const pixiGraphics: any;
|
declare const pixiGraphics: any;
|
||||||
declare const pixiText: any;
|
declare const pixiText: any;
|
||||||
|
|
||||||
// --- Типы ---
|
|
||||||
type HorizontalAlign = "left" | "center" | "right";
|
type HorizontalAlign = "left" | "center" | "right";
|
||||||
type VerticalAlign = "top" | "center" | "bottom";
|
type VerticalAlign = "top" | "center" | "bottom";
|
||||||
type TextAlign = HorizontalAlign | `${HorizontalAlign} ${VerticalAlign}`;
|
type TextAlign = HorizontalAlign | `${HorizontalAlign} ${VerticalAlign}`;
|
||||||
type LabelAlign = "left" | "center" | "right";
|
type LabelAlign = "left" | "center" | "right";
|
||||||
|
|
||||||
// --- Утилиты ---
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Преобразует текстовое позиционирование в anchor координаты.
|
* Преобразует текстовое позиционирование в anchor координаты.
|
||||||
*/
|
*/
|
||||||
@@ -39,8 +32,6 @@ type LabelAlign = "left" | "center" | "right";
|
|||||||
* Получает координату anchor.x из типа выравнивания.
|
* Получает координату anchor.x из типа выравнивания.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// --- Интерфейсы пропсов ---
|
|
||||||
|
|
||||||
interface StationProps {
|
interface StationProps {
|
||||||
station: StationData;
|
station: StationData;
|
||||||
ruLabel: string | null;
|
ruLabel: string | null;
|
||||||
@@ -83,10 +74,6 @@ const getAnchorFromOffset = (
|
|||||||
return { x: (1 - nx) / 2, y: (1 - ny) / 2 };
|
return { x: (1 - nx) / 2, y: (1 - ny) / 2 };
|
||||||
};
|
};
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// Компонент: Панель управления выравниванием в стиле УрФУ
|
|
||||||
// =========================================================================
|
|
||||||
|
|
||||||
const LabelAlignmentControl: FC<LabelAlignmentControlProps> = ({
|
const LabelAlignmentControl: FC<LabelAlignmentControlProps> = ({
|
||||||
scale,
|
scale,
|
||||||
currentAlign,
|
currentAlign,
|
||||||
@@ -107,7 +94,6 @@ const LabelAlignmentControl: FC<LabelAlignmentControlProps> = ({
|
|||||||
(g: Graphics) => {
|
(g: Graphics) => {
|
||||||
g.clear();
|
g.clear();
|
||||||
|
|
||||||
// Основной фон с градиентом
|
|
||||||
g.roundRect(
|
g.roundRect(
|
||||||
-controlWidth / 2,
|
-controlWidth / 2,
|
||||||
0,
|
0,
|
||||||
@@ -115,9 +101,8 @@ const LabelAlignmentControl: FC<LabelAlignmentControlProps> = ({
|
|||||||
controlHeight,
|
controlHeight,
|
||||||
borderRadius
|
borderRadius
|
||||||
);
|
);
|
||||||
g.fill({ color: "#1a1a1a" }); // Темный фон как у УрФУ
|
g.fill({ color: "#1a1a1a" });
|
||||||
|
|
||||||
// Тонкая рамка
|
|
||||||
g.roundRect(
|
g.roundRect(
|
||||||
-controlWidth / 2,
|
-controlWidth / 2,
|
||||||
0,
|
0,
|
||||||
@@ -127,7 +112,6 @@ const LabelAlignmentControl: FC<LabelAlignmentControlProps> = ({
|
|||||||
);
|
);
|
||||||
g.stroke({ color: "#333333", width: strokeWidth });
|
g.stroke({ color: "#333333", width: strokeWidth });
|
||||||
|
|
||||||
// Разделители между кнопками
|
|
||||||
for (let i = 1; i < 3; i++) {
|
for (let i = 1; i < 3; i++) {
|
||||||
const x = -controlWidth / 2 + buttonWidth * i;
|
const x = -controlWidth / 2 + buttonWidth * i;
|
||||||
g.moveTo(x, strokeWidth);
|
g.moveTo(x, strokeWidth);
|
||||||
@@ -151,7 +135,7 @@ const LabelAlignmentControl: FC<LabelAlignmentControlProps> = ({
|
|||||||
controlHeight - strokeWidth * 2,
|
controlHeight - strokeWidth * 2,
|
||||||
borderRadius / 2
|
borderRadius / 2
|
||||||
);
|
);
|
||||||
g.fill({ color: "#0066cc", alpha: 0.8 }); // Синий акцент УрФУ
|
g.fill({ color: "#0066cc", alpha: 0.8 });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[controlWidth, controlHeight, buttonWidth, strokeWidth, borderRadius]
|
[controlWidth, controlHeight, buttonWidth, strokeWidth, borderRadius]
|
||||||
@@ -230,10 +214,6 @@ const LabelAlignmentControl: FC<LabelAlignmentControlProps> = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// Компонент: Метка Станции (с логикой)
|
|
||||||
// =========================================================================
|
|
||||||
|
|
||||||
const StationLabel = observer(
|
const StationLabel = observer(
|
||||||
({
|
({
|
||||||
station,
|
station,
|
||||||
@@ -274,48 +254,45 @@ const StationLabel = observer(
|
|||||||
hideTimer.current = null;
|
hideTimer.current = null;
|
||||||
}
|
}
|
||||||
setIsHovered(true);
|
setIsHovered(true);
|
||||||
onTextHover?.(true); // Call the callback to indicate text is hovered
|
onTextHover?.(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleControlPointerEnter = () => {
|
const handleControlPointerEnter = () => {
|
||||||
// Дополнительная обработка для панели управления
|
|
||||||
if (hideTimer.current) {
|
if (hideTimer.current) {
|
||||||
clearTimeout(hideTimer.current);
|
clearTimeout(hideTimer.current);
|
||||||
hideTimer.current = null;
|
hideTimer.current = null;
|
||||||
}
|
}
|
||||||
setIsControlHovered(true);
|
setIsControlHovered(true);
|
||||||
setIsHovered(true);
|
setIsHovered(true);
|
||||||
onTextHover?.(true); // Call the callback to indicate text/control is hovered
|
onTextHover?.(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleControlPointerLeave = () => {
|
const handleControlPointerLeave = () => {
|
||||||
setIsControlHovered(false);
|
setIsControlHovered(false);
|
||||||
// Если курсор не над основным контейнером, скрываем панель через некоторое время
|
|
||||||
if (!isHovered) {
|
if (!isHovered) {
|
||||||
hideTimer.current = setTimeout(() => {
|
hideTimer.current = setTimeout(() => {
|
||||||
setIsHovered(false);
|
setIsHovered(false);
|
||||||
onTextHover?.(false); // Call the callback to indicate neither text nor control is hovered
|
onTextHover?.(false);
|
||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePointerLeave = () => {
|
const handlePointerLeave = () => {
|
||||||
// Увеличиваем время до скрытия панели и добавляем проверку
|
|
||||||
hideTimer.current = setTimeout(() => {
|
hideTimer.current = setTimeout(() => {
|
||||||
setIsHovered(false);
|
setIsHovered(false);
|
||||||
// Если курсор не над панелью управления, скрываем и её
|
|
||||||
if (!isControlHovered) {
|
if (!isControlHovered) {
|
||||||
setIsControlHovered(false);
|
setIsControlHovered(false);
|
||||||
}
|
}
|
||||||
onTextHover?.(false); // Call the callback to indicate text is no longer hovered
|
onTextHover?.(false);
|
||||||
}, 100); // Увеличиваем время до скрытия панели
|
}, 100);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPosition({ x: station.offset_x ?? 0, y: station.offset_y ?? 0 });
|
setPosition({ x: station.offset_x ?? 0, y: station.offset_y ?? 0 });
|
||||||
}, [station.offset_x, station.offset_y, station.id]);
|
}, [station.offset_x, station.offset_y, station.id]);
|
||||||
|
|
||||||
// Функция для конвертации числового align в строковый
|
|
||||||
const convertNumericAlign = (align: number): LabelAlign => {
|
const convertNumericAlign = (align: number): LabelAlign => {
|
||||||
switch (align) {
|
switch (align) {
|
||||||
case 0:
|
case 0:
|
||||||
@@ -329,7 +306,6 @@ const StationLabel = observer(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Функция для конвертации строкового align в числовой
|
|
||||||
const convertStringAlign = (align: LabelAlign): number => {
|
const convertStringAlign = (align: LabelAlign): number => {
|
||||||
switch (align) {
|
switch (align) {
|
||||||
case "left":
|
case "left":
|
||||||
@@ -353,7 +329,6 @@ const StationLabel = observer(
|
|||||||
const compensatedRuFontSize = (26 * 0.75) / scale;
|
const compensatedRuFontSize = (26 * 0.75) / scale;
|
||||||
const compensatedNameFontSize = (16 * 0.75) / scale;
|
const compensatedNameFontSize = (16 * 0.75) / scale;
|
||||||
|
|
||||||
// Измеряем ширину верхнего лейбла
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (ruLabelRef.current && ruLabel) {
|
if (ruLabelRef.current && ruLabel) {
|
||||||
setRuLabelWidth(ruLabelRef.current.width);
|
setRuLabelWidth(ruLabelRef.current.width);
|
||||||
@@ -386,7 +361,6 @@ const StationLabel = observer(
|
|||||||
y: dragStartPos.current.y + dy_screen,
|
y: dragStartPos.current.y + dy_screen,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Проверяем, изменилась ли позиция
|
|
||||||
if (
|
if (
|
||||||
Math.abs(newPosition.x - position.x) > 0.01 ||
|
Math.abs(newPosition.x - position.x) > 0.01 ||
|
||||||
Math.abs(newPosition.y - position.y) > 0.01
|
Math.abs(newPosition.y - position.y) > 0.01
|
||||||
@@ -406,7 +380,7 @@ const StationLabel = observer(
|
|||||||
const handleAlignChange = async (align: LabelAlign) => {
|
const handleAlignChange = async (align: LabelAlign) => {
|
||||||
setCurrentLabelAlign(align);
|
setCurrentLabelAlign(align);
|
||||||
onLabelAlignChange?.(align);
|
onLabelAlignChange?.(align);
|
||||||
// Сохраняем в стор
|
|
||||||
const numericAlign = convertStringAlign(align);
|
const numericAlign = convertStringAlign(align);
|
||||||
setStationAlign(station.id, numericAlign);
|
setStationAlign(station.id, numericAlign);
|
||||||
};
|
};
|
||||||
@@ -416,34 +390,29 @@ const StationLabel = observer(
|
|||||||
[position.x, position.y]
|
[position.x, position.y]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Функция для расчета позиции нижнего лейбла относительно ширины верхнего
|
|
||||||
const getSecondLabelPosition = (): number => {
|
const getSecondLabelPosition = (): number => {
|
||||||
if (!ruLabelWidth) return 0;
|
if (!ruLabelWidth) return 0;
|
||||||
|
|
||||||
switch (currentLabelAlign) {
|
switch (currentLabelAlign) {
|
||||||
case "left":
|
case "left":
|
||||||
// Позиционируем относительно левого края верхнего текста
|
|
||||||
return -ruLabelWidth / 2;
|
return -ruLabelWidth / 2;
|
||||||
case "center":
|
case "center":
|
||||||
// Центрируем относительно центра верхнего текста
|
|
||||||
return 0;
|
return 0;
|
||||||
case "right":
|
case "right":
|
||||||
// Позиционируем относительно правого края верхнего текста
|
|
||||||
return ruLabelWidth / 2;
|
return ruLabelWidth / 2;
|
||||||
default:
|
default:
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Функция для расчета anchor нижнего лейбла
|
|
||||||
const getSecondLabelAnchor = (): number => {
|
const getSecondLabelAnchor = (): number => {
|
||||||
switch (currentLabelAlign) {
|
switch (currentLabelAlign) {
|
||||||
case "left":
|
case "left":
|
||||||
return 0; // anchor.x = 0 (левый край)
|
return 0;
|
||||||
case "center":
|
case "center":
|
||||||
return 0.5; // anchor.x = 0.5 (центр)
|
return 0.5;
|
||||||
case "right":
|
case "right":
|
||||||
return 1; // anchor.x = 1 (правый край)
|
return 1;
|
||||||
default:
|
default:
|
||||||
return 0.5;
|
return 0.5;
|
||||||
}
|
}
|
||||||
@@ -522,10 +491,6 @@ const StationLabel = observer(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// Главный экспортируемый компонент: Станция
|
|
||||||
// =========================================================================
|
|
||||||
|
|
||||||
export const Station = ({
|
export const Station = ({
|
||||||
station,
|
station,
|
||||||
ruLabel,
|
ruLabel,
|
||||||
@@ -548,10 +513,9 @@ export const Station = ({
|
|||||||
|
|
||||||
g.circle(coordinates.x * UP_SCALE, coordinates.y * UP_SCALE, radius);
|
g.circle(coordinates.x * UP_SCALE, coordinates.y * UP_SCALE, radius);
|
||||||
|
|
||||||
// Change fill color when text is hovered
|
|
||||||
if (isTextHovered) {
|
if (isTextHovered) {
|
||||||
g.fill({ color: 0x00aaff }); // Highlight color when hovered
|
g.fill({ color: 0x00aaff });
|
||||||
g.stroke({ color: 0xffffff, width: strokeWidth + 1 }); // Brighter outline when hovered
|
g.stroke({ color: 0xffffff, width: strokeWidth + 1 });
|
||||||
} else {
|
} else {
|
||||||
g.fill({ color: PATH_COLOR });
|
g.fill({ color: PATH_COLOR });
|
||||||
g.stroke({ color: BACKGROUND_COLOR, width: strokeWidth });
|
g.stroke({ color: BACKGROUND_COLOR, width: strokeWidth });
|
||||||
|
|||||||
@@ -50,7 +50,6 @@ const TransformContext = createContext<{
|
|||||||
setScaleAtCenter: () => {},
|
setScaleAtCenter: () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Provider component
|
|
||||||
export const TransformProvider = ({ children }: { children: ReactNode }) => {
|
export const TransformProvider = ({ children }: { children: ReactNode }) => {
|
||||||
const [position, setPosition] = useState({ x: 0, y: 0 });
|
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||||
const [scale, setScale] = useState(1);
|
const [scale, setScale] = useState(1);
|
||||||
@@ -59,12 +58,10 @@ export const TransformProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
|
|
||||||
const screenToLocal = useCallback(
|
const screenToLocal = useCallback(
|
||||||
(screenX: number, screenY: number) => {
|
(screenX: number, screenY: number) => {
|
||||||
// Translate point relative to current pan position
|
|
||||||
const translatedX = (screenX - position.x) / scale;
|
const translatedX = (screenX - position.x) / scale;
|
||||||
const translatedY = (screenY - position.y) / scale;
|
const translatedY = (screenY - position.y) / scale;
|
||||||
|
|
||||||
// Rotate point around center
|
const cosRotation = Math.cos(-rotation);
|
||||||
const cosRotation = Math.cos(-rotation); // Negative rotation to reverse transform
|
|
||||||
const sinRotation = Math.sin(-rotation);
|
const sinRotation = Math.sin(-rotation);
|
||||||
const rotatedX = translatedX * cosRotation - translatedY * sinRotation;
|
const rotatedX = translatedX * cosRotation - translatedY * sinRotation;
|
||||||
const rotatedY = translatedX * sinRotation + translatedY * cosRotation;
|
const rotatedY = translatedX * sinRotation + translatedY * cosRotation;
|
||||||
@@ -77,7 +74,6 @@ export const TransformProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
[position.x, position.y, scale, rotation]
|
[position.x, position.y, scale, rotation]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Inverse of screenToLocal
|
|
||||||
const localToScreen = useCallback(
|
const localToScreen = useCallback(
|
||||||
(localX: number, localY: number) => {
|
(localX: number, localY: number) => {
|
||||||
const upscaledX = localX * UP_SCALE;
|
const upscaledX = localX * UP_SCALE;
|
||||||
@@ -120,7 +116,6 @@ export const TransformProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
(currentFromPosition.x - center.x) * sinDelta,
|
(currentFromPosition.x - center.x) * sinDelta,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Update both rotation and position in a single batch to avoid stale closure
|
|
||||||
setRotation(to);
|
setRotation(to);
|
||||||
setPosition(newPosition);
|
setPosition(newPosition);
|
||||||
},
|
},
|
||||||
@@ -150,13 +145,11 @@ export const TransformProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
const cosRot = Math.cos(selectedRotation);
|
const cosRot = Math.cos(selectedRotation);
|
||||||
const sinRot = Math.sin(selectedRotation);
|
const sinRot = Math.sin(selectedRotation);
|
||||||
|
|
||||||
// Translate point relative to center, rotate, then translate back
|
|
||||||
const dx = newPosition.x;
|
const dx = newPosition.x;
|
||||||
const dy = newPosition.y;
|
const dy = newPosition.y;
|
||||||
newPosition.x = dx * cosRot - dy * sinRot + center.x;
|
newPosition.x = dx * cosRot - dy * sinRot + center.x;
|
||||||
newPosition.y = dx * sinRot + dy * cosRot + center.y;
|
newPosition.y = dx * sinRot + dy * cosRot + center.y;
|
||||||
|
|
||||||
// Batch state updates to avoid intermediate renders
|
|
||||||
setPosition(newPosition);
|
setPosition(newPosition);
|
||||||
setRotation(selectedRotation);
|
setRotation(selectedRotation);
|
||||||
setScale(selectedScale);
|
setScale(selectedScale);
|
||||||
@@ -184,7 +177,6 @@ export const TransformProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const setScaleOnly = useCallback((newScale: number) => {
|
const setScaleOnly = useCallback((newScale: number) => {
|
||||||
// Изменяем только масштаб, не трогая позицию и поворот
|
|
||||||
setScale(newScale);
|
setScale(newScale);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -237,7 +229,6 @@ export const TransformProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Custom hook for easy access to transform values
|
|
||||||
export const useTransform = () => {
|
export const useTransform = () => {
|
||||||
const context = useContext(TransformContext);
|
const context = useContext(TransformContext);
|
||||||
if (!context) {
|
if (!context) {
|
||||||
|
|||||||
1725
src/pages/Route/route-preview/web-gl/web-gl-version.tsx
Normal file
1725
src/pages/Route/route-preview/web-gl/web-gl-version.tsx
Normal file
File diff suppressed because it is too large
Load Diff
321
src/pages/Sight/LinkedStations.tsx
Normal file
321
src/pages/Sight/LinkedStations.tsx
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
Stack,
|
||||||
|
Typography,
|
||||||
|
Button,
|
||||||
|
Accordion,
|
||||||
|
AccordionSummary,
|
||||||
|
AccordionDetails,
|
||||||
|
useTheme,
|
||||||
|
TextField,
|
||||||
|
Autocomplete,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
Table,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
Paper,
|
||||||
|
TableBody,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||||
|
|
||||||
|
import { 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);
|
||||||
|
|
||||||
|
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));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (updatedLinkedItems) {
|
||||||
|
setLinkedItems(updatedLinkedItems);
|
||||||
|
}
|
||||||
|
}, [updatedLinkedItems]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setItemsParent?.(linkedItems);
|
||||||
|
}, [linkedItems, setItemsParent]);
|
||||||
|
|
||||||
|
const linkItem = () => {
|
||||||
|
if (selectedItemId !== null) {
|
||||||
|
setError(null);
|
||||||
|
const requestData = {
|
||||||
|
station_id: selectedItemId,
|
||||||
|
};
|
||||||
|
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteItem = (itemId: number) => {
|
||||||
|
setError(null);
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
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>
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
color="error"
|
||||||
|
size="small"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
deleteItem(item.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Отвязать
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{linkedItems.length === 0 && !isLoading && (
|
||||||
|
<Typography color="textSecondary" textAlign="center" py={2}>
|
||||||
|
Остановки не найдены
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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" }}
|
||||||
|
>
|
||||||
|
Добавить
|
||||||
|
</Button>
|
||||||
|
</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;
|
||||||
@@ -1 +1,2 @@
|
|||||||
export * from "./SightListPage";
|
export * from "./SightListPage";
|
||||||
|
export { LinkedStations } from "./LinkedStations";
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export const StationCreatePage = observer(() => {
|
|||||||
const { cities, getCities } = cityStore;
|
const { cities, getCities } = cityStore;
|
||||||
const { selectedCityId, selectedCity } = useSelectedCity();
|
const { selectedCityId, selectedCity } = useSelectedCity();
|
||||||
const [coordinates, setCoordinates] = useState<string>("");
|
const [coordinates, setCoordinates] = useState<string>("");
|
||||||
// НОВОЕ СОСТОЯНИЕ ДЛЯ ПРЕДУПРЕЖДАЮЩЕГО ОКНА
|
|
||||||
const [isSaveWarningOpen, setIsSaveWarningOpen] = useState(false);
|
const [isSaveWarningOpen, setIsSaveWarningOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -49,7 +49,6 @@ export const StationCreatePage = observer(() => {
|
|||||||
}
|
}
|
||||||
}, [createStationData.common.latitude, createStationData.common.longitude]);
|
}, [createStationData.common.latitude, createStationData.common.longitude]);
|
||||||
|
|
||||||
// НОВАЯ ФУНКЦИЯ: Фактическое создание (вызывается после подтверждения)
|
|
||||||
const executeCreate = async () => {
|
const executeCreate = async () => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@@ -64,11 +63,13 @@ export const StationCreatePage = observer(() => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ОБНОВЛЕННАЯ ФУНКЦИЯ: Проверка и вызов окна или создания
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
const isCityMissing = !createStationData.common.city_id;
|
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) {
|
if (isCityMissing || isNameMissing) {
|
||||||
setIsSaveWarningOpen(true);
|
setIsSaveWarningOpen(true);
|
||||||
@@ -78,13 +79,11 @@ export const StationCreatePage = observer(() => {
|
|||||||
await executeCreate();
|
await executeCreate();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Обработчик "Да" в предупреждающем окне
|
|
||||||
const handleConfirmCreate = async () => {
|
const handleConfirmCreate = async () => {
|
||||||
setIsSaveWarningOpen(false);
|
setIsSaveWarningOpen(false);
|
||||||
await executeCreate();
|
await executeCreate();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Обработчик "Нет" в предупреждающем окне
|
|
||||||
const handleCancelCreate = () => {
|
const handleCancelCreate = () => {
|
||||||
setIsSaveWarningOpen(false);
|
setIsSaveWarningOpen(false);
|
||||||
};
|
};
|
||||||
@@ -99,7 +98,6 @@ export const StationCreatePage = observer(() => {
|
|||||||
fetchCities();
|
fetchCities();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Автоматически устанавливаем выбранный город при загрузке страницы
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedCityId && selectedCity && !createStationData.common.city_id) {
|
if (selectedCityId && selectedCity && !createStationData.common.city_id) {
|
||||||
setCreateCommonData({
|
setCreateCommonData({
|
||||||
@@ -237,7 +235,7 @@ export const StationCreatePage = observer(() => {
|
|||||||
className="w-min flex gap-2 items-center"
|
className="w-min flex gap-2 items-center"
|
||||||
startIcon={<Save size={20} />}
|
startIcon={<Save size={20} />}
|
||||||
onClick={handleCreate}
|
onClick={handleCreate}
|
||||||
disabled={isLoading} // Убрал проверку name/city_id отсюда, чтобы ее обрабатывал handleCreate
|
disabled={isLoading}
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Loader2 size={20} className="animate-spin" />
|
<Loader2 size={20} className="animate-spin" />
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export const StationEditPage = observer(() => {
|
|||||||
} = stationsStore;
|
} = stationsStore;
|
||||||
const { cities, getCities } = cityStore;
|
const { cities, getCities } = cityStore;
|
||||||
const [coordinates, setCoordinates] = useState<string>("");
|
const [coordinates, setCoordinates] = useState<string>("");
|
||||||
// НОВОЕ СОСТОЯНИЕ ДЛЯ ПРЕДУПРЕЖДАЮЩЕГО ОКНА
|
|
||||||
const [isSaveWarningOpen, setIsSaveWarningOpen] = useState(false);
|
const [isSaveWarningOpen, setIsSaveWarningOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -50,7 +50,6 @@ export const StationEditPage = observer(() => {
|
|||||||
}
|
}
|
||||||
}, [editStationData.common.latitude, editStationData.common.longitude]);
|
}, [editStationData.common.latitude, editStationData.common.longitude]);
|
||||||
|
|
||||||
// НОВАЯ ФУНКЦИЯ: Фактическое редактирование (вызывается после подтверждения)
|
|
||||||
const executeEdit = async () => {
|
const executeEdit = async () => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@@ -64,10 +63,9 @@ export const StationEditPage = observer(() => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ОБНОВЛЕННАЯ ФУНКЦИЯ: Проверка и вызов окна или редактирования
|
|
||||||
const handleEdit = async () => {
|
const handleEdit = async () => {
|
||||||
const isCityMissing = !editStationData.common.city_id;
|
const isCityMissing = !editStationData.common.city_id;
|
||||||
// Проверяем названия на всех языках
|
|
||||||
const isNameMissing =
|
const isNameMissing =
|
||||||
!editStationData.ru.name ||
|
!editStationData.ru.name ||
|
||||||
!editStationData.en.name ||
|
!editStationData.en.name ||
|
||||||
@@ -81,13 +79,11 @@ export const StationEditPage = observer(() => {
|
|||||||
await executeEdit();
|
await executeEdit();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Обработчик "Да" в предупреждающем окне
|
|
||||||
const handleConfirmEdit = async () => {
|
const handleConfirmEdit = async () => {
|
||||||
setIsSaveWarningOpen(false);
|
setIsSaveWarningOpen(false);
|
||||||
await executeEdit();
|
await executeEdit();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Обработчик "Нет" в предупреждающем окне
|
|
||||||
const handleCancelEdit = () => {
|
const handleCancelEdit = () => {
|
||||||
setIsSaveWarningOpen(false);
|
setIsSaveWarningOpen(false);
|
||||||
};
|
};
|
||||||
@@ -243,7 +239,7 @@ export const StationEditPage = observer(() => {
|
|||||||
className="w-min flex gap-2 items-center"
|
className="w-min flex gap-2 items-center"
|
||||||
startIcon={<Save size={20} />}
|
startIcon={<Save size={20} />}
|
||||||
onClick={handleEdit}
|
onClick={handleEdit}
|
||||||
disabled={isLoading} // Убрал проверку name/city_id отсюда, чтобы ее обрабатывал handleEdit
|
disabled={isLoading}
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Loader2 size={20} className="animate-spin" />
|
<Loader2 size={20} className="animate-spin" />
|
||||||
|
|||||||
@@ -8,13 +8,10 @@ import {
|
|||||||
Earth,
|
Earth,
|
||||||
Landmark,
|
Landmark,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
// Car,
|
|
||||||
Table,
|
Table,
|
||||||
Split,
|
Split,
|
||||||
// Newspaper,
|
|
||||||
PersonStanding,
|
PersonStanding,
|
||||||
Cpu,
|
Cpu,
|
||||||
// BookImage,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import carrierIcon from "./carrier.svg";
|
import carrierIcon from "./carrier.svg";
|
||||||
@@ -57,12 +54,6 @@ export const NAVIGATION_ITEMS: {
|
|||||||
path: "/devices",
|
path: "/devices",
|
||||||
for_admin: true,
|
for_admin: true,
|
||||||
},
|
},
|
||||||
// {
|
|
||||||
// id: "vehicles",
|
|
||||||
// label: "Транспорт",
|
|
||||||
// icon: Car,
|
|
||||||
// path: "/vehicle",
|
|
||||||
// },
|
|
||||||
{
|
{
|
||||||
id: "users",
|
id: "users",
|
||||||
label: "Пользователи",
|
label: "Пользователи",
|
||||||
@@ -75,18 +66,6 @@ export const NAVIGATION_ITEMS: {
|
|||||||
label: "Справочник",
|
label: "Справочник",
|
||||||
icon: Table,
|
icon: Table,
|
||||||
nestedItems: [
|
nestedItems: [
|
||||||
// {
|
|
||||||
// id: "media",
|
|
||||||
// label: "Медиа",
|
|
||||||
// icon: BookImage,
|
|
||||||
// path: "/media",
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// id: "articles",
|
|
||||||
// label: "Статьи",
|
|
||||||
// icon: Newspaper,
|
|
||||||
// path: "/article",
|
|
||||||
// },
|
|
||||||
{
|
{
|
||||||
id: "attractions",
|
id: "attractions",
|
||||||
label: "Достопримечательности",
|
label: "Достопримечательности",
|
||||||
@@ -124,7 +103,7 @@ export const NAVIGATION_ITEMS: {
|
|||||||
id: "carriers",
|
id: "carriers",
|
||||||
label: "Перевозчики",
|
label: "Перевозчики",
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
icon: () => <img src={carrierIcon} alt="Перевозчики"/>,
|
icon: () => <img src={carrierIcon} alt="Перевозчики" />,
|
||||||
path: "/carrier",
|
path: "/carrier",
|
||||||
for_admin: true,
|
for_admin: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,8 +1,3 @@
|
|||||||
/**
|
|
||||||
* Утилита для управления кешем GLTF и blob URL
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Динамический импорт useGLTF для избежания проблем с SSR
|
|
||||||
let useGLTF: any = null;
|
let useGLTF: any = null;
|
||||||
|
|
||||||
const initializeUseGLTF = async () => {
|
const initializeUseGLTF = async () => {
|
||||||
@@ -20,9 +15,6 @@ const initializeUseGLTF = async () => {
|
|||||||
return useGLTF;
|
return useGLTF;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Очищает кеш GLTF для конкретного URL
|
|
||||||
*/
|
|
||||||
export const clearGLTFCacheForUrl = async (url: string) => {
|
export const clearGLTFCacheForUrl = async (url: string) => {
|
||||||
try {
|
try {
|
||||||
const gltf = await initializeUseGLTF();
|
const gltf = await initializeUseGLTF();
|
||||||
@@ -32,9 +24,6 @@ export const clearGLTFCacheForUrl = async (url: string) => {
|
|||||||
} catch (error) {}
|
} catch (error) {}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Очищает весь кеш GLTF
|
|
||||||
*/
|
|
||||||
export const clearAllGLTFCache = async () => {
|
export const clearAllGLTFCache = async () => {
|
||||||
try {
|
try {
|
||||||
const gltf = await initializeUseGLTF();
|
const gltf = await initializeUseGLTF();
|
||||||
@@ -44,9 +33,6 @@ export const clearAllGLTFCache = async () => {
|
|||||||
} catch (error) {}
|
} catch (error) {}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Очищает blob URL из памяти браузера
|
|
||||||
*/
|
|
||||||
export const revokeBlobURL = (url: string) => {
|
export const revokeBlobURL = (url: string) => {
|
||||||
if (url && url.startsWith("blob:")) {
|
if (url && url.startsWith("blob:")) {
|
||||||
try {
|
try {
|
||||||
@@ -55,27 +41,16 @@ export const revokeBlobURL = (url: string) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Комплексная очистка: blob URL + кеш GLTF
|
|
||||||
*/
|
|
||||||
export const clearBlobAndGLTFCache = async (url: string) => {
|
export const clearBlobAndGLTFCache = async (url: string) => {
|
||||||
// Сначала отзываем blob URL
|
|
||||||
revokeBlobURL(url);
|
revokeBlobURL(url);
|
||||||
|
|
||||||
// Затем очищаем кеш GLTF
|
|
||||||
await clearGLTFCacheForUrl(url);
|
await clearGLTFCacheForUrl(url);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Очистка при смене медиа (для предотвращения конфликтов)
|
|
||||||
*/
|
|
||||||
export const clearMediaTransitionCache = async (
|
export const clearMediaTransitionCache = async (
|
||||||
previousMediaId: string | number | null,
|
previousMediaId: string | number | null,
|
||||||
newMediaId: string | number | null,
|
|
||||||
newMediaType?: number
|
newMediaType?: number
|
||||||
) => {
|
) => {
|
||||||
console.log(newMediaId, newMediaType);
|
|
||||||
// Если переключаемся с/на 3D модель, очищаем весь кеш
|
|
||||||
if (newMediaType === 6 || previousMediaId) {
|
if (newMediaType === 6 || previousMediaId) {
|
||||||
await clearAllGLTFCache();
|
await clearAllGLTFCache();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,34 +2,17 @@ export * from "./mui/theme";
|
|||||||
export * from "./DecodeJWT";
|
export * from "./DecodeJWT";
|
||||||
export * from "./gltfCacheManager";
|
export * from "./gltfCacheManager";
|
||||||
|
|
||||||
/**
|
|
||||||
* Генерирует название медиа по умолчанию в разных форматах
|
|
||||||
*
|
|
||||||
* Примеры использования:
|
|
||||||
* - Для достопримечательности с названием: "Михайловский замок_mikhail-zamok_Фото"
|
|
||||||
* - Для достопримечательности без названия: "Название_mikhail-zamok_Фото"
|
|
||||||
* - Для статьи: "Михайловский замок_mikhail-zamok_Факты" (где "Факты" - название статьи)
|
|
||||||
*
|
|
||||||
* @param objectName - Название объекта (достопримечательности, города и т.д.)
|
|
||||||
* @param fileName - Название файла
|
|
||||||
* @param mediaType - Тип медиа (число) или название статьи
|
|
||||||
* @param isArticle - Флаг, указывающий что медиа добавляется к статье
|
|
||||||
* @returns Строка в нужном формате
|
|
||||||
*/
|
|
||||||
export const generateDefaultMediaName = (
|
export const generateDefaultMediaName = (
|
||||||
objectName: string,
|
objectName: string,
|
||||||
fileName: string,
|
fileName: string,
|
||||||
mediaType: number | string,
|
mediaType: number | string,
|
||||||
isArticle: boolean = false
|
isArticle: boolean = false
|
||||||
): string => {
|
): string => {
|
||||||
// Убираем расширение из названия файла
|
|
||||||
const fileNameWithoutExtension = fileName.split(".").slice(0, -1).join(".");
|
const fileNameWithoutExtension = fileName.split(".").slice(0, -1).join(".");
|
||||||
|
|
||||||
if (isArticle && typeof mediaType === "string") {
|
if (isArticle && typeof mediaType === "string") {
|
||||||
// Для статей: "Название достопримечательности_название файла_название статьи"
|
|
||||||
return `${objectName}_${fileNameWithoutExtension}_${mediaType}`;
|
return `${objectName}_${fileNameWithoutExtension}_${mediaType}`;
|
||||||
} else if (typeof mediaType === "number") {
|
} else if (typeof mediaType === "number") {
|
||||||
// Получаем название типа медиа
|
|
||||||
const mediaTypeLabels: Record<number, string> = {
|
const mediaTypeLabels: Record<number, string> = {
|
||||||
1: "Фото",
|
1: "Фото",
|
||||||
2: "Видео",
|
2: "Видео",
|
||||||
@@ -42,14 +25,11 @@ export const generateDefaultMediaName = (
|
|||||||
const mediaTypeLabel = mediaTypeLabels[mediaType] || "Медиа";
|
const mediaTypeLabel = mediaTypeLabels[mediaType] || "Медиа";
|
||||||
|
|
||||||
if (objectName && objectName.trim() !== "") {
|
if (objectName && objectName.trim() !== "") {
|
||||||
// Если есть название объекта: "Название объекта_название файла_тип медиа"
|
|
||||||
return `${objectName}_${fileNameWithoutExtension}_${mediaTypeLabel}`;
|
return `${objectName}_${fileNameWithoutExtension}_${mediaTypeLabel}`;
|
||||||
} else {
|
} else {
|
||||||
// Если нет названия объекта: "Название_название файла_тип медиа"
|
|
||||||
return `Название_${fileNameWithoutExtension}_${mediaTypeLabel}`;
|
return `Название_${fileNameWithoutExtension}_${mediaTypeLabel}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback
|
|
||||||
return `${objectName || "Название"}_${fileNameWithoutExtension}_Медиа`;
|
return `${objectName || "Название"}_${fileNameWithoutExtension}_Медиа`;
|
||||||
};
|
};
|
||||||
|
|||||||
1067
src/shared/modals/ArticleSelectOrCreateDialog/index.tsx
Normal file
1067
src/shared/modals/ArticleSelectOrCreateDialog/index.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -54,7 +54,7 @@ interface UploadMediaDialogProps {
|
|||||||
| "station";
|
| "station";
|
||||||
isArticle?: boolean;
|
isArticle?: boolean;
|
||||||
articleName?: string;
|
articleName?: string;
|
||||||
initialFile?: File; // <--- добавлено
|
initialFile?: File;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const UploadMediaDialog = observer(
|
export const UploadMediaDialog = observer(
|
||||||
@@ -68,7 +68,7 @@ export const UploadMediaDialog = observer(
|
|||||||
|
|
||||||
isArticle,
|
isArticle,
|
||||||
articleName,
|
articleName,
|
||||||
initialFile, // <--- добавлено
|
initialFile,
|
||||||
}: UploadMediaDialogProps) => {
|
}: UploadMediaDialogProps) => {
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -87,7 +87,6 @@ export const UploadMediaDialog = observer(
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialFile) {
|
if (initialFile) {
|
||||||
// Очищаем предыдущий blob URL если он существует
|
|
||||||
if (
|
if (
|
||||||
previousMediaUrlRef.current &&
|
previousMediaUrlRef.current &&
|
||||||
previousMediaUrlRef.current.startsWith("blob:")
|
previousMediaUrlRef.current.startsWith("blob:")
|
||||||
@@ -106,7 +105,6 @@ export const UploadMediaDialog = observer(
|
|||||||
}
|
}
|
||||||
}, [initialFile]);
|
}, [initialFile]);
|
||||||
|
|
||||||
// Очистка blob URL при размонтировании компонента
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (
|
if (
|
||||||
@@ -116,13 +114,13 @@ export const UploadMediaDialog = observer(
|
|||||||
clearBlobAndGLTFCache(previousMediaUrlRef.current);
|
clearBlobAndGLTFCache(previousMediaUrlRef.current);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, []); // Пустой массив зависимостей - выполняется только при размонтировании
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (fileToUpload) {
|
if (fileToUpload) {
|
||||||
setMediaFile(fileToUpload);
|
setMediaFile(fileToUpload);
|
||||||
setMediaFilename(fileToUpload.name);
|
setMediaFilename(fileToUpload.name);
|
||||||
// Try to determine media type from file extension
|
|
||||||
const extension = fileToUpload.name.split(".").pop()?.toLowerCase();
|
const extension = fileToUpload.name.split(".").pop()?.toLowerCase();
|
||||||
if (extension) {
|
if (extension) {
|
||||||
if (["glb", "gltf"].includes(extension)) {
|
if (["glb", "gltf"].includes(extension)) {
|
||||||
@@ -134,22 +132,18 @@ export const UploadMediaDialog = observer(
|
|||||||
extension
|
extension
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
// Для изображений доступны все типы кроме видео
|
setAvailableMediaTypes([1, 3, 4, 5]);
|
||||||
setAvailableMediaTypes([1, 3, 4, 5]); // Фото, Иконка, Водяной знак, Панорама, 3Д-модель
|
setMediaType(1);
|
||||||
setMediaType(1); // По умолчанию Фото
|
|
||||||
} else if (["mp4", "webm", "mov"].includes(extension)) {
|
} else if (["mp4", "webm", "mov"].includes(extension)) {
|
||||||
// Для видео только тип Видео
|
|
||||||
setAvailableMediaTypes([2]);
|
setAvailableMediaTypes([2]);
|
||||||
setMediaType(2);
|
setMediaType(2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Генерируем название по умолчанию если есть контекст
|
|
||||||
if (fileToUpload.name) {
|
if (fileToUpload.name) {
|
||||||
let defaultName = "";
|
let defaultName = "";
|
||||||
|
|
||||||
if (isArticle && articleName && contextObjectName) {
|
if (isArticle && articleName && contextObjectName) {
|
||||||
// Для статей: "Название достопримечательности_название файла_название статьи"
|
|
||||||
defaultName = generateDefaultMediaName(
|
defaultName = generateDefaultMediaName(
|
||||||
contextObjectName,
|
contextObjectName,
|
||||||
fileToUpload.name,
|
fileToUpload.name,
|
||||||
@@ -157,10 +151,9 @@ export const UploadMediaDialog = observer(
|
|||||||
true
|
true
|
||||||
);
|
);
|
||||||
} else if (contextObjectName && contextObjectName.trim() !== "") {
|
} else if (contextObjectName && contextObjectName.trim() !== "") {
|
||||||
// Для обычных медиа с названием объекта
|
|
||||||
const currentMediaType = hardcodeType
|
const currentMediaType = hardcodeType
|
||||||
? MEDIA_TYPE_VALUES[hardcodeType]
|
? MEDIA_TYPE_VALUES[hardcodeType]
|
||||||
: 1; // По умолчанию фото
|
: 1;
|
||||||
defaultName = generateDefaultMediaName(
|
defaultName = generateDefaultMediaName(
|
||||||
contextObjectName,
|
contextObjectName,
|
||||||
fileToUpload.name,
|
fileToUpload.name,
|
||||||
@@ -168,10 +161,9 @@ export const UploadMediaDialog = observer(
|
|||||||
false
|
false
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Для медиа без названия объекта
|
|
||||||
const currentMediaType = hardcodeType
|
const currentMediaType = hardcodeType
|
||||||
? MEDIA_TYPE_VALUES[hardcodeType]
|
? MEDIA_TYPE_VALUES[hardcodeType]
|
||||||
: 1; // По умолчанию фото
|
: 1;
|
||||||
defaultName = generateDefaultMediaName(
|
defaultName = generateDefaultMediaName(
|
||||||
"",
|
"",
|
||||||
fileToUpload.name,
|
fileToUpload.name,
|
||||||
@@ -185,13 +177,11 @@ export const UploadMediaDialog = observer(
|
|||||||
}
|
}
|
||||||
}, [fileToUpload, contextObjectName, hardcodeType, isArticle, articleName]);
|
}, [fileToUpload, contextObjectName, hardcodeType, isArticle, articleName]);
|
||||||
|
|
||||||
// Обновляем название при изменении типа медиа
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (mediaFilename && mediaType > 0) {
|
if (mediaFilename && mediaType > 0) {
|
||||||
let defaultName = "";
|
let defaultName = "";
|
||||||
|
|
||||||
if (isArticle && articleName && contextObjectName) {
|
if (isArticle && articleName && contextObjectName) {
|
||||||
// Для статей: "Название достопримечательности_название файла_название статьи"
|
|
||||||
defaultName = generateDefaultMediaName(
|
defaultName = generateDefaultMediaName(
|
||||||
contextObjectName,
|
contextObjectName,
|
||||||
mediaFilename,
|
mediaFilename,
|
||||||
@@ -199,7 +189,6 @@ export const UploadMediaDialog = observer(
|
|||||||
true
|
true
|
||||||
);
|
);
|
||||||
} else if (contextObjectName && contextObjectName.trim() !== "") {
|
} else if (contextObjectName && contextObjectName.trim() !== "") {
|
||||||
// Для обычных медиа с названием объекта
|
|
||||||
const currentMediaType = hardcodeType
|
const currentMediaType = hardcodeType
|
||||||
? MEDIA_TYPE_VALUES[hardcodeType]
|
? MEDIA_TYPE_VALUES[hardcodeType]
|
||||||
: mediaType;
|
: mediaType;
|
||||||
@@ -210,7 +199,6 @@ export const UploadMediaDialog = observer(
|
|||||||
false
|
false
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Для медиа без названия объекта
|
|
||||||
const currentMediaType = hardcodeType
|
const currentMediaType = hardcodeType
|
||||||
? MEDIA_TYPE_VALUES[hardcodeType]
|
? MEDIA_TYPE_VALUES[hardcodeType]
|
||||||
: mediaType;
|
: mediaType;
|
||||||
@@ -235,7 +223,6 @@ export const UploadMediaDialog = observer(
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (mediaFile) {
|
if (mediaFile) {
|
||||||
// Очищаем предыдущий blob URL и кеш GLTF если он существует
|
|
||||||
if (
|
if (
|
||||||
previousMediaUrlRef.current &&
|
previousMediaUrlRef.current &&
|
||||||
previousMediaUrlRef.current.startsWith("blob:")
|
previousMediaUrlRef.current.startsWith("blob:")
|
||||||
@@ -245,22 +232,10 @@ export const UploadMediaDialog = observer(
|
|||||||
|
|
||||||
const newBlobUrl = URL.createObjectURL(mediaFile as Blob);
|
const newBlobUrl = URL.createObjectURL(mediaFile as Blob);
|
||||||
setMediaUrl(newBlobUrl);
|
setMediaUrl(newBlobUrl);
|
||||||
previousMediaUrlRef.current = newBlobUrl; // Сохраняем новый URL в ref
|
previousMediaUrlRef.current = newBlobUrl;
|
||||||
setIsPreviewLoaded(false); // Сбрасываем состояние загрузки при смене файла
|
setIsPreviewLoaded(false);
|
||||||
}
|
}
|
||||||
}, [mediaFile]); // Убираем mediaUrl из зависимостей чтобы избежать зацикливания
|
}, [mediaFile]);
|
||||||
|
|
||||||
// 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]);
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!mediaFile) return;
|
if (!mediaFile) return;
|
||||||
@@ -285,10 +260,10 @@ export const UploadMediaDialog = observer(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
setSuccess(true);
|
setSuccess(true);
|
||||||
// Закрываем модальное окно после успешного сохранения
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
handleClose();
|
handleClose();
|
||||||
}, 1000); // Небольшая задержка, чтобы пользователь увидел сообщение об успехе
|
}, 1000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Failed to save media");
|
setError(err instanceof Error ? err.message : "Failed to save media");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -297,7 +272,6 @@ export const UploadMediaDialog = observer(
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
// Очищаем blob URL и кеш GLTF при закрытии диалога
|
|
||||||
if (
|
if (
|
||||||
previousMediaUrlRef.current &&
|
previousMediaUrlRef.current &&
|
||||||
previousMediaUrlRef.current.startsWith("blob:")
|
previousMediaUrlRef.current.startsWith("blob:")
|
||||||
@@ -310,7 +284,7 @@ export const UploadMediaDialog = observer(
|
|||||||
setMediaUrl(null);
|
setMediaUrl(null);
|
||||||
setMediaFile(null);
|
setMediaFile(null);
|
||||||
setIsPreviewLoaded(false);
|
setIsPreviewLoaded(false);
|
||||||
previousMediaUrlRef.current = null; // Очищаем ref
|
previousMediaUrlRef.current = null;
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,3 +2,4 @@ export * from "./SelectArticleDialog";
|
|||||||
export * from "./SelectMediaDialog";
|
export * from "./SelectMediaDialog";
|
||||||
export * from "./PreviewMediaDialog";
|
export * from "./PreviewMediaDialog";
|
||||||
export * from "./UploadMediaDialog";
|
export * from "./UploadMediaDialog";
|
||||||
|
export * from "./ArticleSelectOrCreateDialog";
|
||||||
|
|||||||
@@ -171,7 +171,6 @@ class CarrierStore {
|
|||||||
this.carriers[language].data.push(response.data);
|
this.carriers[language].data.push(response.data);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create translations for other languages
|
|
||||||
for (const lang of ["ru", "en", "zh"].filter((l) => l !== language)) {
|
for (const lang of ["ru", "en", "zh"].filter((l) => l !== language)) {
|
||||||
const patchPayload = {
|
const patchPayload = {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
// @shared/stores/createSightStore.ts
|
|
||||||
import {
|
import {
|
||||||
articlesStore,
|
articlesStore,
|
||||||
Language,
|
Language,
|
||||||
@@ -27,7 +26,6 @@ type SightLanguageInfo = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type SightCommonInfo = {
|
type SightCommonInfo = {
|
||||||
// id: number; // ID is 0 until created
|
|
||||||
city_id: number;
|
city_id: number;
|
||||||
city: string;
|
city: string;
|
||||||
latitude: number;
|
latitude: number;
|
||||||
@@ -35,13 +33,11 @@ type SightCommonInfo = {
|
|||||||
thumbnail: string | null;
|
thumbnail: string | null;
|
||||||
watermark_lu: string | null;
|
watermark_lu: string | null;
|
||||||
watermark_rd: 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;
|
preview_media: string | null;
|
||||||
video_preview: 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 & {
|
type SightBaseInfo = SightCommonInfo & {
|
||||||
[key in Language]: SightLanguageInfo;
|
[key in Language]: SightLanguageInfo;
|
||||||
};
|
};
|
||||||
@@ -78,7 +74,7 @@ const initialSightState: SightBaseInfo = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
class CreateSightStore {
|
class CreateSightStore {
|
||||||
sight: SightBaseInfo = JSON.parse(JSON.stringify(initialSightState)); // Deep copy for reset
|
sight: SightBaseInfo = JSON.parse(JSON.stringify(initialSightState));
|
||||||
|
|
||||||
uploadMediaOpen = false;
|
uploadMediaOpen = false;
|
||||||
setUploadMediaOpen = (open: boolean) => {
|
setUploadMediaOpen = (open: boolean) => {
|
||||||
@@ -93,9 +89,7 @@ class CreateSightStore {
|
|||||||
makeAutoObservable(this);
|
makeAutoObservable(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Right Article Management ---
|
|
||||||
createNewRightArticle = async () => {
|
createNewRightArticle = async () => {
|
||||||
// Create article in DB for all languages
|
|
||||||
const articleRuData = {
|
const articleRuData = {
|
||||||
heading: "Новый заголовок (RU)",
|
heading: "Новый заголовок (RU)",
|
||||||
body: "Новый текст (RU)",
|
body: "Новый текст (RU)",
|
||||||
@@ -125,7 +119,7 @@ class CreateSightStore {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const { id } = articleRes.data; // New article's ID
|
const { id } = articleRes.data;
|
||||||
|
|
||||||
runInAction(() => {
|
runInAction(() => {
|
||||||
const newArticleEntry = { id, media: [] };
|
const newArticleEntry = { id, media: [] };
|
||||||
@@ -133,7 +127,7 @@ class CreateSightStore {
|
|||||||
this.sight.en.right.push({ ...newArticleEntry, ...articleEnData });
|
this.sight.en.right.push({ ...newArticleEntry, ...articleEnData });
|
||||||
this.sight.zh.right.push({ ...newArticleEntry, ...articleZhData });
|
this.sight.zh.right.push({ ...newArticleEntry, ...articleZhData });
|
||||||
});
|
});
|
||||||
return id; // Return ID for potential immediate use
|
return id;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error creating new right article:", error);
|
console.error("Error creating new right article:", error);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -169,7 +163,7 @@ class CreateSightStore {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return articleId; // Return the linked article ID
|
return articleId;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error linking existing right article:", error);
|
console.error("Error linking existing right article:", error);
|
||||||
throw 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) => {
|
unlinkRightAritcle = (articleId: number) => {
|
||||||
// Changed from 'unlinkRightAritcle' spelling
|
|
||||||
runInAction(() => {
|
runInAction(() => {
|
||||||
this.sight.ru.right = this.sight.ru.right.filter(
|
this.sight.ru.right = this.sight.ru.right.filter(
|
||||||
(article) => article.id !== articleId
|
(article) => article.id !== articleId
|
||||||
@@ -202,16 +194,12 @@ class CreateSightStore {
|
|||||||
(article) => article.id !== articleId
|
(article) => article.id !== articleId
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
// Note: If this article was created via createNewRightArticle, it still exists in the DB.
|
|
||||||
// Consider if an orphaned article should be deleted here or managed separately.
|
|
||||||
// For now, it just removes it from the list associated with *this specific sight creation process*.
|
|
||||||
};
|
};
|
||||||
|
|
||||||
deleteRightArticle = async (articleId: number) => {
|
deleteRightArticle = async (articleId: number) => {
|
||||||
try {
|
try {
|
||||||
await authInstance.delete(`/article/${articleId}`); // Delete from backend
|
await authInstance.delete(`/article/${articleId}`);
|
||||||
runInAction(() => {
|
runInAction(() => {
|
||||||
// Remove from local store for all languages
|
|
||||||
this.sight.ru.right = this.sight.ru.right.filter(
|
this.sight.ru.right = this.sight.ru.right.filter(
|
||||||
(article) => article.id !== articleId
|
(article) => article.id !== articleId
|
||||||
);
|
);
|
||||||
@@ -228,12 +216,11 @@ class CreateSightStore {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Right Article Media Management ---
|
|
||||||
createLinkWithRightArticle = async (media: MediaItem, articleId: number) => {
|
createLinkWithRightArticle = async (media: MediaItem, articleId: number) => {
|
||||||
try {
|
try {
|
||||||
await authInstance.post(`/article/${articleId}/media`, {
|
await authInstance.post(`/article/${articleId}/media`, {
|
||||||
media_id: media.id,
|
media_id: media.id,
|
||||||
media_order: 1, // Or calculate based on existing media.length + 1
|
media_order: 1,
|
||||||
});
|
});
|
||||||
runInAction(() => {
|
runInAction(() => {
|
||||||
(["ru", "en", "zh"] as Language[]).forEach((lang) => {
|
(["ru", "en", "zh"] as Language[]).forEach((lang) => {
|
||||||
@@ -242,7 +229,7 @@ class CreateSightStore {
|
|||||||
);
|
);
|
||||||
if (article) {
|
if (article) {
|
||||||
if (!article.media) article.media = [];
|
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) => {
|
updateLeftInfo = (language: Language, heading: string, body: string) => {
|
||||||
this.sight[language].left.heading = heading;
|
this.sight[language].left.heading = heading;
|
||||||
this.sight[language].left.body = body;
|
this.sight[language].left.body = body;
|
||||||
@@ -323,7 +309,7 @@ class CreateSightStore {
|
|||||||
deleteLeftArticle = async (articleId: number) => {
|
deleteLeftArticle = async (articleId: number) => {
|
||||||
/* ... your existing logic ... */
|
/* ... your existing logic ... */
|
||||||
await authInstance.delete(`/article/${articleId}`);
|
await authInstance.delete(`/article/${articleId}`);
|
||||||
// articlesStore.getArticles(languageStore.language); // If still neede
|
|
||||||
runInAction(() => {
|
runInAction(() => {
|
||||||
articlesStore.articles.ru = articlesStore.articles.ru.filter(
|
articlesStore.articles.ru = articlesStore.articles.ru.filter(
|
||||||
(article) => article.id !== articleId
|
(article) => article.id !== articleId
|
||||||
@@ -340,63 +326,69 @@ class CreateSightStore {
|
|||||||
|
|
||||||
createLeftArticle = async () => {
|
createLeftArticle = async () => {
|
||||||
/* ... your existing logic to create a new left article (placeholder or DB) ... */
|
/* ... 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", {
|
const response = await languageInstance("ru").post("/article", {
|
||||||
heading: "Новая левая статья",
|
heading: hasAnyName ? ruName : "",
|
||||||
body: "Заполните контентом",
|
body: "",
|
||||||
});
|
});
|
||||||
const newLeftArticleId = response.data.id;
|
const newLeftArticleId = response.data.id;
|
||||||
|
|
||||||
await languageInstance("en").patch(`/article/${newLeftArticleId}`, {
|
await languageInstance("en").patch(`/article/${newLeftArticleId}`, {
|
||||||
heading: "New Left Article",
|
heading: hasAnyName ? enName : "",
|
||||||
body: "Fill with content",
|
body: "",
|
||||||
});
|
});
|
||||||
await languageInstance("zh").patch(`/article/${newLeftArticleId}`, {
|
await languageInstance("zh").patch(`/article/${newLeftArticleId}`, {
|
||||||
heading: "新的左侧文章",
|
heading: hasAnyName ? zhName : "",
|
||||||
body: "填写内容",
|
body: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
runInAction(() => {
|
runInAction(() => {
|
||||||
this.sight.left_article = newLeftArticleId; // Store the actual ID
|
this.sight.left_article = newLeftArticleId;
|
||||||
this.sight.ru.left = {
|
this.sight.ru.left = {
|
||||||
heading: "Новая левая статья",
|
heading: hasAnyName ? ruName : "",
|
||||||
body: "Заполните контентом",
|
body: "",
|
||||||
media: [],
|
media: [],
|
||||||
};
|
};
|
||||||
this.sight.en.left = {
|
this.sight.en.left = {
|
||||||
heading: "New Left Article",
|
heading: hasAnyName ? enName : "",
|
||||||
body: "Fill with content",
|
body: "",
|
||||||
media: [],
|
media: [],
|
||||||
};
|
};
|
||||||
this.sight.zh.left = {
|
this.sight.zh.left = {
|
||||||
heading: "新的左侧文章",
|
heading: hasAnyName ? zhName : "",
|
||||||
body: "填写内容",
|
body: "",
|
||||||
media: [],
|
media: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
articlesStore.articles.ru.push({
|
articlesStore.articles.ru.push({
|
||||||
id: newLeftArticleId,
|
id: newLeftArticleId,
|
||||||
heading: "Новая левая статья",
|
heading: hasAnyName ? ruName : "",
|
||||||
body: "Заполните контентом",
|
body: "",
|
||||||
service_name: "Новая левая статья",
|
service_name: hasAnyName ? ruName : "",
|
||||||
});
|
});
|
||||||
articlesStore.articles.en.push({
|
articlesStore.articles.en.push({
|
||||||
id: newLeftArticleId,
|
id: newLeftArticleId,
|
||||||
heading: "New Left Article",
|
heading: hasAnyName ? enName : "",
|
||||||
body: "Fill with content",
|
body: "",
|
||||||
service_name: "New Left Article",
|
service_name: hasAnyName ? enName : "",
|
||||||
});
|
});
|
||||||
articlesStore.articles.zh.push({
|
articlesStore.articles.zh.push({
|
||||||
id: newLeftArticleId,
|
id: newLeftArticleId,
|
||||||
heading: "新的左侧文章",
|
heading: hasAnyName ? zhName : "",
|
||||||
body: "填写内容",
|
body: "",
|
||||||
service_name: "新的左侧文章",
|
service_name: hasAnyName ? zhName : "",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
return newLeftArticleId;
|
return newLeftArticleId;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Placeholder for a "new" unsaved left article
|
|
||||||
setNewLeftArticlePlaceholder = () => {
|
setNewLeftArticlePlaceholder = () => {
|
||||||
this.sight.left_article = 10000000; // Special placeholder ID
|
this.sight.left_article = 10000000;
|
||||||
this.sight.ru.left = {
|
this.sight.ru.left = {
|
||||||
heading: "Новая левая статья",
|
heading: "Новая левая статья",
|
||||||
body: "Заполните контентом",
|
body: "Заполните контентом",
|
||||||
@@ -414,7 +406,6 @@ class CreateSightStore {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Sight Preview Media ---
|
|
||||||
linkPreviewMedia = (mediaId: string) => {
|
linkPreviewMedia = (mediaId: string) => {
|
||||||
this.sight.preview_media = mediaId;
|
this.sight.preview_media = mediaId;
|
||||||
};
|
};
|
||||||
@@ -423,32 +414,27 @@ class CreateSightStore {
|
|||||||
this.sight.preview_media = null;
|
this.sight.preview_media = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- General Store Methods ---
|
|
||||||
clearCreateSight = () => {
|
clearCreateSight = () => {
|
||||||
this.needLeaveAgree = false;
|
this.needLeaveAgree = false;
|
||||||
this.sight = JSON.parse(JSON.stringify(initialSightState)); // Reset to initial
|
this.sight = JSON.parse(JSON.stringify(initialSightState));
|
||||||
};
|
};
|
||||||
|
|
||||||
updateSightInfo = (
|
updateSightInfo = (
|
||||||
content: Partial<SightLanguageInfo | SightCommonInfo>, // Corrected types
|
content: Partial<SightLanguageInfo | SightCommonInfo>,
|
||||||
language?: Language
|
language?: Language
|
||||||
) => {
|
) => {
|
||||||
this.needLeaveAgree = true;
|
this.needLeaveAgree = true;
|
||||||
if (language) {
|
if (language) {
|
||||||
this.sight[language] = { ...this.sight[language], ...content };
|
this.sight[language] = { ...this.sight[language], ...content };
|
||||||
} else {
|
} else {
|
||||||
// Assuming content here is for SightCommonInfo
|
|
||||||
this.sight = { ...this.sight, ...(content as Partial<SightCommonInfo>) };
|
this.sight = { ...this.sight, ...(content as Partial<SightCommonInfo>) };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Main Sight Creation Logic ---
|
|
||||||
createSight = async (primaryLanguage: Language) => {
|
createSight = async (primaryLanguage: Language) => {
|
||||||
let finalLeftArticleId = this.sight.left_article;
|
let finalLeftArticleId = this.sight.left_article;
|
||||||
|
|
||||||
// 1. Handle Left Article (Create if new, or use existing ID)
|
|
||||||
if (this.sight.left_article === 10000000) {
|
if (this.sight.left_article === 10000000) {
|
||||||
// Placeholder for new
|
|
||||||
const res = await languageInstance("ru").post("/article", {
|
const res = await languageInstance("ru").post("/article", {
|
||||||
heading: this.sight.ru.left.heading,
|
heading: this.sight.ru.left.heading,
|
||||||
body: this.sight.ru.left.body,
|
body: this.sight.ru.left.body,
|
||||||
@@ -466,7 +452,6 @@ class CreateSightStore {
|
|||||||
this.sight.left_article !== 0 &&
|
this.sight.left_article !== 0 &&
|
||||||
this.sight.left_article !== null
|
this.sight.left_article !== null
|
||||||
) {
|
) {
|
||||||
// Existing, ensure it's up-to-date
|
|
||||||
await languageInstance("ru").patch(
|
await languageInstance("ru").patch(
|
||||||
`/article/${this.sight.left_article}`,
|
`/article/${this.sight.left_article}`,
|
||||||
{ heading: this.sight.ru.left.heading, body: this.sight.ru.left.body }
|
{ heading: this.sight.ru.left.heading, body: this.sight.ru.left.body }
|
||||||
@@ -480,10 +465,7 @@ class CreateSightStore {
|
|||||||
{ heading: this.sight.zh.left.heading, body: this.sight.zh.left.body }
|
{ heading: this.sight.zh.left.heading, body: this.sight.zh.left.body }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// else: left_article is 0, so no left article
|
|
||||||
|
|
||||||
// 2. Right articles are already created in DB and their IDs are in this.sight[lang].right.
|
|
||||||
// We just need to update their content if changed before saving the sight.
|
|
||||||
for (const lang of ["ru", "en", "zh"] as Language[]) {
|
for (const lang of ["ru", "en", "zh"] as Language[]) {
|
||||||
for (const article of this.sight[lang].right) {
|
for (const article of this.sight[lang].right) {
|
||||||
if (article.id == 0 || article.id == null) {
|
if (article.id == 0 || article.id == null) {
|
||||||
@@ -493,14 +475,12 @@ class CreateSightStore {
|
|||||||
heading: article.heading,
|
heading: article.heading,
|
||||||
body: article.body,
|
body: article.body,
|
||||||
});
|
});
|
||||||
// Media for these articles are already linked via createLinkWithRightArticle
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const rightArticleIdsForLink = this.sight[primaryLanguage].right.map(
|
const rightArticleIdsForLink = this.sight[primaryLanguage].right.map(
|
||||||
(a) => a.id
|
(a) => a.id
|
||||||
);
|
);
|
||||||
|
|
||||||
// 3. Create Sight object in DB
|
|
||||||
const sightPayload = {
|
const sightPayload = {
|
||||||
city_id: this.sight.city_id,
|
city_id: this.sight.city_id,
|
||||||
city: this.sight.city,
|
city: this.sight.city,
|
||||||
@@ -520,9 +500,8 @@ class CreateSightStore {
|
|||||||
"/sight",
|
"/sight",
|
||||||
sightPayload
|
sightPayload
|
||||||
);
|
);
|
||||||
const newSightId = response.data.id; // ID of the newly created sight
|
const newSightId = response.data.id;
|
||||||
|
|
||||||
// 4. Update other languages for the sight
|
|
||||||
const otherLanguages = (["ru", "en", "zh"] as Language[]).filter(
|
const otherLanguages = (["ru", "en", "zh"] as Language[]).filter(
|
||||||
(l) => l !== primaryLanguage
|
(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++) {
|
for (let i = 0; i < rightArticleIdsForLink.length; i++) {
|
||||||
await authInstance.post(`/sight/${newSightId}/article`, {
|
await authInstance.post(`/sight/${newSightId}/article`, {
|
||||||
article_id: rightArticleIdsForLink[i],
|
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;
|
this.needLeaveAgree = false;
|
||||||
return newSightId;
|
return newSightId;
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Media Upload (Generic, used by dialogs) ---
|
|
||||||
uploadMedia = async (
|
uploadMedia = async (
|
||||||
filename: string,
|
filename: string,
|
||||||
type: number,
|
type: number,
|
||||||
@@ -575,12 +551,12 @@ class CreateSightStore {
|
|||||||
this.fileToUpload = null;
|
this.fileToUpload = null;
|
||||||
this.uploadMediaOpen = false;
|
this.uploadMediaOpen = false;
|
||||||
});
|
});
|
||||||
mediaStore.getMedia(); // Refresh global media list
|
mediaStore.getMedia();
|
||||||
return {
|
return {
|
||||||
id: response.data.id,
|
id: response.data.id,
|
||||||
filename: filename, // Or response.data.filename if backend returns it
|
filename: filename,
|
||||||
media_name: media_name, // Or response.data.media_name
|
media_name: media_name,
|
||||||
media_type: type, // Or response.data.type
|
media_type: type,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error uploading media:", error);
|
console.error("Error uploading media:", error);
|
||||||
@@ -588,15 +564,12 @@ class CreateSightStore {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// For Left Article Media
|
|
||||||
createLinkWithLeftArticle = async (media: MediaItem) => {
|
createLinkWithLeftArticle = async (media: MediaItem) => {
|
||||||
if (!this.sight.left_article || this.sight.left_article === 10000000) {
|
if (!this.sight.left_article || this.sight.left_article === 10000000) {
|
||||||
console.warn(
|
console.warn(
|
||||||
"Left article not selected or is a placeholder. Cannot link media yet."
|
"Left article not selected or is a placeholder. Cannot link media yet."
|
||||||
);
|
);
|
||||||
// If it's a placeholder, we could store the media temporarily and link it after the article is created.
|
|
||||||
// For simplicity, we'll assume the article must exist.
|
|
||||||
// A more robust solution might involve creating the article first if it's a placeholder.
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -655,7 +628,7 @@ class CreateSightStore {
|
|||||||
|
|
||||||
this.sight.ru.right = sortArticles(this.sight.ru.right);
|
this.sight.ru.right = sortArticles(this.sight.ru.right);
|
||||||
this.sight.en.right = sortArticles(this.sight.en.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;
|
this.needLeaveAgree = true;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
// @shared/stores/editSightStore.ts
|
|
||||||
import {
|
import {
|
||||||
articlesStore,
|
articlesStore,
|
||||||
authInstance,
|
authInstance,
|
||||||
@@ -96,13 +95,11 @@ class EditSightStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
runInAction(() => {
|
runInAction(() => {
|
||||||
// Обновляем языковую часть
|
|
||||||
this.sight[language] = {
|
this.sight[language] = {
|
||||||
...this.sight[language],
|
...this.sight[language],
|
||||||
...data,
|
...data,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Только при первом запросе обновляем общую часть
|
|
||||||
if (!this.hasLoadedCommon) {
|
if (!this.hasLoadedCommon) {
|
||||||
this.sight.common = {
|
this.sight.common = {
|
||||||
...this.sight.common,
|
...this.sight.common,
|
||||||
@@ -123,7 +120,6 @@ class EditSightStore {
|
|||||||
let responseEn = await languageInstance("en").get(`/sight/${id}/article`);
|
let responseEn = await languageInstance("en").get(`/sight/${id}/article`);
|
||||||
let responseZh = await languageInstance("zh").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();
|
const mediaMap = new Map();
|
||||||
for (const article of responseRu.data) {
|
for (const article of responseRu.data) {
|
||||||
const responseMedia = await authInstance.get(
|
const responseMedia = await authInstance.get(
|
||||||
@@ -132,7 +128,6 @@ class EditSightStore {
|
|||||||
mediaMap.set(article.id, responseMedia.data);
|
mediaMap.set(article.id, responseMedia.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Function to add media to articles
|
|
||||||
const addMediaToArticles = (articles: any[]) => {
|
const addMediaToArticles = (articles: any[]) => {
|
||||||
return articles.map((article) => ({
|
return articles.map((article) => ({
|
||||||
...article,
|
...article,
|
||||||
@@ -327,28 +322,6 @@ class EditSightStore {
|
|||||||
articles: articleIdsInObject,
|
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;
|
this.needLeaveAgree = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -400,16 +373,36 @@ class EditSightStore {
|
|||||||
};
|
};
|
||||||
|
|
||||||
createLeftArticle = async () => {
|
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`, {
|
const response = await languageInstance("ru").post(`/article`, {
|
||||||
heading: "",
|
heading: hasAnyName ? ruName : "",
|
||||||
body: "",
|
body: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
this.sight.common.left_article = response.data.id;
|
this.sight.common.left_article = response.data.id;
|
||||||
|
|
||||||
this.sight.ru.left.heading = "";
|
await languageInstance("en").patch(
|
||||||
this.sight.en.left.heading = "";
|
`/article/${this.sight.common.left_article}`,
|
||||||
this.sight.zh.left.heading = "";
|
{
|
||||||
|
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.ru.left.body = "";
|
||||||
this.sight.en.left.body = "";
|
this.sight.en.left.body = "";
|
||||||
this.sight.zh.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) => {
|
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 (
|
createLinkWithRightArticle = async (
|
||||||
@@ -750,7 +743,7 @@ class EditSightStore {
|
|||||||
|
|
||||||
this.sight.ru.right = sortArticles(this.sight.ru.right);
|
this.sight.ru.right = sortArticles(this.sight.ru.right);
|
||||||
this.sight.en.right = sortArticles(this.sight.en.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;
|
this.needLeaveAgree = true;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -39,12 +39,11 @@ class MediaStore {
|
|||||||
updateMedia = async (id: string, data: Partial<Media>) => {
|
updateMedia = async (id: string, data: Partial<Media>) => {
|
||||||
const response = await authInstance.patch(`/media/${id}`, data);
|
const response = await authInstance.patch(`/media/${id}`, data);
|
||||||
runInAction(() => {
|
runInAction(() => {
|
||||||
// Update in media array
|
|
||||||
const index = this.media.findIndex((m) => m.id === id);
|
const index = this.media.findIndex((m) => m.id === id);
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
this.media[index] = { ...this.media[index], ...response.data };
|
this.media[index] = { ...this.media[index], ...response.data };
|
||||||
}
|
}
|
||||||
// Update oneMedia if it's the current media being viewed
|
|
||||||
if (this.oneMedia?.id === id) {
|
if (this.oneMedia?.id === id) {
|
||||||
this.oneMedia = { ...this.oneMedia, ...response.data };
|
this.oneMedia = { ...this.oneMedia, ...response.data };
|
||||||
}
|
}
|
||||||
@@ -64,12 +63,11 @@ class MediaStore {
|
|||||||
});
|
});
|
||||||
|
|
||||||
runInAction(() => {
|
runInAction(() => {
|
||||||
// Update in media array
|
|
||||||
const index = this.media.findIndex((m) => m.id === id);
|
const index = this.media.findIndex((m) => m.id === id);
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
this.media[index] = { ...this.media[index], ...response.data };
|
this.media[index] = { ...this.media[index], ...response.data };
|
||||||
}
|
}
|
||||||
// Update oneMedia if it's the current media being viewed
|
|
||||||
if (this.oneMedia?.id === id) {
|
if (this.oneMedia?.id === id) {
|
||||||
this.oneMedia = { ...this.oneMedia, ...response.data };
|
this.oneMedia = { ...this.oneMedia, ...response.data };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ class ModelLoadingStore {
|
|||||||
makeAutoObservable(this);
|
makeAutoObservable(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Начать отслеживание загрузки модели
|
|
||||||
startLoading(modelId: string) {
|
startLoading(modelId: string) {
|
||||||
this.loadingStates.set(modelId, {
|
this.loadingStates.set(modelId, {
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
@@ -25,7 +24,6 @@ class ModelLoadingStore {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Обновить прогресс загрузки
|
|
||||||
updateProgress(modelId: string, progress: number) {
|
updateProgress(modelId: string, progress: number) {
|
||||||
const state = this.loadingStates.get(modelId);
|
const state = this.loadingStates.get(modelId);
|
||||||
if (state) {
|
if (state) {
|
||||||
@@ -33,7 +31,6 @@ class ModelLoadingStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Завершить загрузку модели
|
|
||||||
finishLoading(modelId: string) {
|
finishLoading(modelId: string) {
|
||||||
const state = this.loadingStates.get(modelId);
|
const state = this.loadingStates.get(modelId);
|
||||||
if (state) {
|
if (state) {
|
||||||
@@ -42,12 +39,10 @@ class ModelLoadingStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Остановить загрузку (в случае ошибки)
|
|
||||||
stopLoading(modelId: string) {
|
stopLoading(modelId: string) {
|
||||||
this.loadingStates.delete(modelId);
|
this.loadingStates.delete(modelId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Обработать ошибку загрузки
|
|
||||||
handleError(modelId: string, error?: string) {
|
handleError(modelId: string, error?: string) {
|
||||||
const state = this.loadingStates.get(modelId);
|
const state = this.loadingStates.get(modelId);
|
||||||
if (state) {
|
if (state) {
|
||||||
@@ -56,26 +51,22 @@ class ModelLoadingStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Получить состояние загрузки для конкретной модели
|
|
||||||
getLoadingState(modelId: string): ModelLoadingState | undefined {
|
getLoadingState(modelId: string): ModelLoadingState | undefined {
|
||||||
return this.loadingStates.get(modelId);
|
return this.loadingStates.get(modelId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Проверить, загружается ли какая-либо модель
|
|
||||||
get isAnyModelLoading(): boolean {
|
get isAnyModelLoading(): boolean {
|
||||||
return Array.from(this.loadingStates.values()).some(
|
return Array.from(this.loadingStates.values()).some(
|
||||||
(state) => state.isLoading
|
(state) => state.isLoading
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Получить все загружающиеся модели
|
|
||||||
get loadingModels(): ModelLoadingState[] {
|
get loadingModels(): ModelLoadingState[] {
|
||||||
return Array.from(this.loadingStates.values()).filter(
|
return Array.from(this.loadingStates.values()).filter(
|
||||||
(state) => state.isLoading
|
(state) => state.isLoading
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Получить общий прогресс всех загружающихся моделей
|
|
||||||
get overallProgress(): number {
|
get overallProgress(): number {
|
||||||
const loadingModels = this.loadingModels;
|
const loadingModels = this.loadingModels;
|
||||||
if (loadingModels.length === 0) return 100;
|
if (loadingModels.length === 0) return 100;
|
||||||
@@ -87,12 +78,10 @@ class ModelLoadingStore {
|
|||||||
return Math.round(totalProgress / loadingModels.length);
|
return Math.round(totalProgress / loadingModels.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Проверить, заблокировано ли сохранение (есть ли загружающиеся модели)
|
|
||||||
get isSaveBlocked(): boolean {
|
get isSaveBlocked(): boolean {
|
||||||
return this.isAnyModelLoading;
|
return this.isAnyModelLoading;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Очистить все состояния загрузки
|
|
||||||
clearAll() {
|
clearAll() {
|
||||||
this.loadingStates.clear();
|
this.loadingStates.clear();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { makeAutoObservable, runInAction } from "mobx";
|
|||||||
import { authInstance } from "@shared";
|
import { authInstance } from "@shared";
|
||||||
|
|
||||||
export type Route = {
|
export type Route = {
|
||||||
|
route_name: string;
|
||||||
carrier: string;
|
carrier: string;
|
||||||
carrier_id: number;
|
carrier_id: number;
|
||||||
center_latitude: number;
|
center_latitude: number;
|
||||||
@@ -97,6 +98,7 @@ class RouteStore {
|
|||||||
};
|
};
|
||||||
|
|
||||||
editRouteData = {
|
editRouteData = {
|
||||||
|
route_name: "",
|
||||||
carrier: "",
|
carrier: "",
|
||||||
carrier_id: 0,
|
carrier_id: 0,
|
||||||
center_latitude: "",
|
center_latitude: "",
|
||||||
@@ -110,7 +112,7 @@ class RouteStore {
|
|||||||
route_sys_number: "",
|
route_sys_number: "",
|
||||||
scale_max: 0,
|
scale_max: 0,
|
||||||
scale_min: 0,
|
scale_min: 0,
|
||||||
video_preview: "",
|
video_preview: "" as string | undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
setEditRouteData = (data: any) => {
|
setEditRouteData = (data: any) => {
|
||||||
@@ -118,6 +120,9 @@ class RouteStore {
|
|||||||
};
|
};
|
||||||
|
|
||||||
editRoute = async (id: number) => {
|
editRoute = async (id: number) => {
|
||||||
|
if (!this.editRouteData.video_preview) {
|
||||||
|
delete this.editRouteData.video_preview;
|
||||||
|
}
|
||||||
const response = await authInstance.patch(`/route/${id}`, {
|
const response = await authInstance.patch(`/route/${id}`, {
|
||||||
...this.editRouteData,
|
...this.editRouteData,
|
||||||
center_latitude: parseFloat(this.editRouteData.center_latitude),
|
center_latitude: parseFloat(this.editRouteData.center_latitude),
|
||||||
|
|||||||
@@ -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 (
|
createSightAction = async (
|
||||||
city: number,
|
city: number,
|
||||||
coordinates: { latitude: number; longitude: number }
|
coordinates: { latitude: number; longitude: number }
|
||||||
@@ -167,16 +132,12 @@ class SightsStore {
|
|||||||
common: boolean
|
common: boolean
|
||||||
) => {
|
) => {
|
||||||
if (common) {
|
if (common) {
|
||||||
// @ts-ignore
|
|
||||||
this.sight!.common = {
|
this.sight!.common = {
|
||||||
// @ts-ignore
|
|
||||||
...this.sight!.common,
|
...this.sight!.common,
|
||||||
...content,
|
...content,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
// @ts-ignore
|
|
||||||
this.sight![language] = {
|
this.sight![language] = {
|
||||||
// @ts-ignore
|
|
||||||
...this.sight![language],
|
...this.sight![language],
|
||||||
...content,
|
...content,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { authInstance } from "@shared";
|
import { authInstance } from "@shared";
|
||||||
|
|
||||||
import { makeAutoObservable, runInAction } from "mobx";
|
import { makeAutoObservable, runInAction } from "mobx";
|
||||||
// Импорт функции сброса кешей карты
|
|
||||||
// import { clearMapCaches } from "../../pages/MapPage";
|
|
||||||
import {
|
import {
|
||||||
articlesStore,
|
articlesStore,
|
||||||
cityStore,
|
cityStore,
|
||||||
@@ -35,9 +33,7 @@ class SnapshotStore {
|
|||||||
makeAutoObservable(this);
|
makeAutoObservable(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Функция для сброса всех кешей в приложении
|
|
||||||
private clearAllCaches = () => {
|
private clearAllCaches = () => {
|
||||||
// Сброс кешей статей
|
|
||||||
articlesStore.articleList = {
|
articlesStore.articleList = {
|
||||||
ru: { data: [], loaded: false },
|
ru: { data: [], loaded: false },
|
||||||
en: { data: [], loaded: false },
|
en: { data: [], loaded: false },
|
||||||
@@ -47,7 +43,6 @@ class SnapshotStore {
|
|||||||
articlesStore.articleData = null;
|
articlesStore.articleData = null;
|
||||||
articlesStore.articleMedia = null;
|
articlesStore.articleMedia = null;
|
||||||
|
|
||||||
// Сброс кешей городов
|
|
||||||
cityStore.cities = {
|
cityStore.cities = {
|
||||||
ru: { data: [], loaded: false },
|
ru: { data: [], loaded: false },
|
||||||
en: { data: [], loaded: false },
|
en: { data: [], loaded: false },
|
||||||
@@ -56,21 +51,18 @@ class SnapshotStore {
|
|||||||
cityStore.ruCities = { data: [], loaded: false };
|
cityStore.ruCities = { data: [], loaded: false };
|
||||||
cityStore.city = {};
|
cityStore.city = {};
|
||||||
|
|
||||||
// Сброс кешей стран
|
|
||||||
countryStore.countries = {
|
countryStore.countries = {
|
||||||
ru: { data: [], loaded: false },
|
ru: { data: [], loaded: false },
|
||||||
en: { data: [], loaded: false },
|
en: { data: [], loaded: false },
|
||||||
zh: { data: [], loaded: false },
|
zh: { data: [], loaded: false },
|
||||||
};
|
};
|
||||||
|
|
||||||
// Сброс кешей перевозчиков
|
|
||||||
carrierStore.carriers = {
|
carrierStore.carriers = {
|
||||||
ru: { data: [], loaded: false },
|
ru: { data: [], loaded: false },
|
||||||
en: { data: [], loaded: false },
|
en: { data: [], loaded: false },
|
||||||
zh: { data: [], loaded: false },
|
zh: { data: [], loaded: false },
|
||||||
};
|
};
|
||||||
|
|
||||||
// Сброс кешей станций
|
|
||||||
stationsStore.stationLists = {
|
stationsStore.stationLists = {
|
||||||
ru: { data: [], loaded: false },
|
ru: { data: [], loaded: false },
|
||||||
en: { data: [], loaded: false },
|
en: { data: [], loaded: false },
|
||||||
@@ -78,24 +70,18 @@ class SnapshotStore {
|
|||||||
};
|
};
|
||||||
stationsStore.stationPreview = {};
|
stationsStore.stationPreview = {};
|
||||||
|
|
||||||
// Сброс кешей достопримечательностей
|
|
||||||
sightsStore.sights = [];
|
sightsStore.sights = [];
|
||||||
sightsStore.sight = null;
|
sightsStore.sight = null;
|
||||||
|
|
||||||
// Сброс кешей маршрутов
|
|
||||||
routeStore.routes = { data: [], loaded: false };
|
routeStore.routes = { data: [], loaded: false };
|
||||||
|
|
||||||
// Сброс кешей транспорта
|
|
||||||
vehicleStore.vehicles = { data: [], loaded: false };
|
vehicleStore.vehicles = { data: [], loaded: false };
|
||||||
|
|
||||||
// Сброс кешей пользователей
|
|
||||||
userStore.users = { data: [], loaded: false };
|
userStore.users = { data: [], loaded: false };
|
||||||
|
|
||||||
// Сброс кешей медиа
|
|
||||||
mediaStore.media = [];
|
mediaStore.media = [];
|
||||||
mediaStore.oneMedia = null;
|
mediaStore.oneMedia = null;
|
||||||
|
|
||||||
// Сброс кешей создания и редактирования достопримечательностей
|
|
||||||
createSightStore.sight = JSON.parse(
|
createSightStore.sight = JSON.parse(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
city_id: 0,
|
city_id: 0,
|
||||||
@@ -173,26 +159,21 @@ class SnapshotStore {
|
|||||||
editSightStore.fileToUpload = null;
|
editSightStore.fileToUpload = null;
|
||||||
editSightStore.needLeaveAgree = false;
|
editSightStore.needLeaveAgree = false;
|
||||||
|
|
||||||
// Сброс кешей устройств
|
|
||||||
devicesStore.devices = [];
|
devicesStore.devices = [];
|
||||||
devicesStore.uuid = null;
|
devicesStore.uuid = null;
|
||||||
devicesStore.sendSnapshotModalOpen = false;
|
devicesStore.sendSnapshotModalOpen = false;
|
||||||
|
|
||||||
// Сброс кешей авторизации (кроме токена)
|
|
||||||
authStore.payload = null;
|
authStore.payload = null;
|
||||||
authStore.error = null;
|
authStore.error = null;
|
||||||
authStore.isLoading = false;
|
authStore.isLoading = false;
|
||||||
|
|
||||||
// Сброс кешей карты (если они загружены)
|
|
||||||
try {
|
try {
|
||||||
// Сбрасываем кеши mapStore если он доступен
|
|
||||||
if (typeof window !== "undefined" && (window as any).mapStore) {
|
if (typeof window !== "undefined" && (window as any).mapStore) {
|
||||||
(window as any).mapStore.routes = [];
|
(window as any).mapStore.routes = [];
|
||||||
(window as any).mapStore.stations = [];
|
(window as any).mapStore.stations = [];
|
||||||
(window as any).mapStore.sights = [];
|
(window as any).mapStore.sights = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Сбрасываем кеши MapService если он доступен
|
|
||||||
if (typeof window !== "undefined" && (window as any).mapServiceInstance) {
|
if (typeof window !== "undefined" && (window as any).mapServiceInstance) {
|
||||||
(window as any).mapServiceInstance.clearCaches();
|
(window as any).mapServiceInstance.clearCaches();
|
||||||
}
|
}
|
||||||
@@ -200,7 +181,6 @@ class SnapshotStore {
|
|||||||
console.warn("Не удалось сбросить кеши карты:", error);
|
console.warn("Не удалось сбросить кеши карты:", error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Сброс localStorage кешей (кроме токена авторизации)
|
|
||||||
const token = localStorage.getItem("token");
|
const token = localStorage.getItem("token");
|
||||||
const rememberedEmail = localStorage.getItem("rememberedEmail");
|
const rememberedEmail = localStorage.getItem("rememberedEmail");
|
||||||
const rememberedPassword = localStorage.getItem("rememberedPassword");
|
const rememberedPassword = localStorage.getItem("rememberedPassword");
|
||||||
@@ -208,14 +188,12 @@ class SnapshotStore {
|
|||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
sessionStorage.clear();
|
sessionStorage.clear();
|
||||||
|
|
||||||
// Восстанавливаем важные данные
|
|
||||||
if (token) localStorage.setItem("token", token);
|
if (token) localStorage.setItem("token", token);
|
||||||
if (rememberedEmail)
|
if (rememberedEmail)
|
||||||
localStorage.setItem("rememberedEmail", rememberedEmail);
|
localStorage.setItem("rememberedEmail", rememberedEmail);
|
||||||
if (rememberedPassword)
|
if (rememberedPassword)
|
||||||
localStorage.setItem("rememberedPassword", rememberedPassword);
|
localStorage.setItem("rememberedPassword", rememberedPassword);
|
||||||
|
|
||||||
// Сброс кешей карты (если они есть)
|
|
||||||
const mapPositionKey = "mapPosition";
|
const mapPositionKey = "mapPosition";
|
||||||
const activeSectionKey = "mapActiveSection";
|
const activeSectionKey = "mapActiveSection";
|
||||||
if (localStorage.getItem(mapPositionKey)) {
|
if (localStorage.getItem(mapPositionKey)) {
|
||||||
@@ -225,7 +203,6 @@ class SnapshotStore {
|
|||||||
localStorage.removeItem(activeSectionKey);
|
localStorage.removeItem(activeSectionKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Попытка очистить кеш браузера (если поддерживается)
|
|
||||||
if ("caches" in window) {
|
if ("caches" in window) {
|
||||||
try {
|
try {
|
||||||
caches.keys().then((cacheNames) => {
|
caches.keys().then((cacheNames) => {
|
||||||
@@ -240,7 +217,6 @@ class SnapshotStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Попытка очистить IndexedDB (если поддерживается)
|
|
||||||
if ("indexedDB" in window) {
|
if ("indexedDB" in window) {
|
||||||
try {
|
try {
|
||||||
indexedDB.databases().then((databases) => {
|
indexedDB.databases().then((databases) => {
|
||||||
@@ -284,10 +260,8 @@ class SnapshotStore {
|
|||||||
};
|
};
|
||||||
|
|
||||||
restoreSnapshot = async (id: string) => {
|
restoreSnapshot = async (id: string) => {
|
||||||
// Сначала сбрасываем все кеши
|
|
||||||
this.clearAllCaches();
|
this.clearAllCaches();
|
||||||
|
|
||||||
// Затем восстанавливаем снапшот
|
|
||||||
await authInstance.post(`/snapshots/${id}/restore`);
|
await authInstance.post(`/snapshots/${id}/restore`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ type StationLanguageData = {
|
|||||||
name: string;
|
name: string;
|
||||||
system_name: string;
|
system_name: string;
|
||||||
address: string;
|
address: string;
|
||||||
loaded: boolean; // Indicates if this language's data has been loaded/modified
|
loaded: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type StationCommonData = {
|
type StationCommonData = {
|
||||||
@@ -92,7 +92,6 @@ class StationsStore {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// This will store the full station data, keyed by ID and then by language
|
|
||||||
stationPreview: Record<
|
stationPreview: Record<
|
||||||
string,
|
string,
|
||||||
Record<string, { loaded: boolean; data: Station }>
|
Record<string, { loaded: boolean; data: Station }>
|
||||||
@@ -264,7 +263,6 @@ class StationsStore {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// Sets language-specific station data
|
|
||||||
setLanguageEditStationData = (
|
setLanguageEditStationData = (
|
||||||
language: Language,
|
language: Language,
|
||||||
data: Partial<StationLanguageData>
|
data: Partial<StationLanguageData>
|
||||||
@@ -295,7 +293,7 @@ class StationsStore {
|
|||||||
`/station/${id}`,
|
`/station/${id}`,
|
||||||
{
|
{
|
||||||
name: name || "",
|
name: name || "",
|
||||||
system_name: name || "", // system_name is often derived from name
|
system_name: name || "",
|
||||||
description: description || "",
|
description: description || "",
|
||||||
address: address || "",
|
address: address || "",
|
||||||
...commonDataPayload,
|
...commonDataPayload,
|
||||||
@@ -303,7 +301,6 @@ class StationsStore {
|
|||||||
);
|
);
|
||||||
|
|
||||||
runInAction(() => {
|
runInAction(() => {
|
||||||
// Update the cached preview data and station lists after successful patch
|
|
||||||
if (this.stationPreview[id]) {
|
if (this.stationPreview[id]) {
|
||||||
this.stationPreview[id][language] = {
|
this.stationPreview[id][language] = {
|
||||||
loaded: true,
|
loaded: true,
|
||||||
@@ -343,11 +340,11 @@ class StationsStore {
|
|||||||
|
|
||||||
runInAction(() => {
|
runInAction(() => {
|
||||||
this.stations = this.stations.filter((station) => station.id !== id);
|
this.stations = this.stations.filter((station) => station.id !== id);
|
||||||
// Also clear from stationPreview cache
|
|
||||||
if (this.stationPreview[id]) {
|
if (this.stationPreview[id]) {
|
||||||
delete this.stationPreview[id];
|
delete this.stationPreview[id];
|
||||||
}
|
}
|
||||||
// Clear from stationLists as well for all languages
|
|
||||||
for (const lang of ["ru", "en", "zh"] as const) {
|
for (const lang of ["ru", "en", "zh"] as const) {
|
||||||
if (this.stationLists[lang].data) {
|
if (this.stationLists[lang].data) {
|
||||||
this.stationLists[lang].data = this.stationLists[lang].data.filter(
|
this.stationLists[lang].data = this.stationLists[lang].data.filter(
|
||||||
@@ -421,12 +418,11 @@ class StationsStore {
|
|||||||
delete commonDataPayload.icon;
|
delete commonDataPayload.icon;
|
||||||
}
|
}
|
||||||
|
|
||||||
// First create station in Russian
|
|
||||||
const { name, address } = this.createStationData[language];
|
const { name, address } = this.createStationData[language];
|
||||||
const description = this.createStationData.common.description;
|
const description = this.createStationData.common.description;
|
||||||
const response = await languageInstance(language).post("/station", {
|
const response = await languageInstance(language).post("/station", {
|
||||||
name: name || "",
|
name: name || "",
|
||||||
system_name: name || "", // system_name is often derived from name
|
system_name: name || "",
|
||||||
description: description || "",
|
description: description || "",
|
||||||
address: address || "",
|
address: address || "",
|
||||||
...commonDataPayload,
|
...commonDataPayload,
|
||||||
@@ -438,7 +434,6 @@ class StationsStore {
|
|||||||
|
|
||||||
const stationId = response.data.id;
|
const stationId = response.data.id;
|
||||||
|
|
||||||
// Then update for other languages
|
|
||||||
for (const lang of ["ru", "en", "zh"].filter(
|
for (const lang of ["ru", "en", "zh"].filter(
|
||||||
(lang) => lang !== language
|
(lang) => lang !== language
|
||||||
) as Language[]) {
|
) as Language[]) {
|
||||||
@@ -448,7 +443,7 @@ class StationsStore {
|
|||||||
`/station/${stationId}`,
|
`/station/${stationId}`,
|
||||||
{
|
{
|
||||||
name: name || "",
|
name: name || "",
|
||||||
system_name: name || "", // system_name is often derived from name
|
system_name: name || "",
|
||||||
description: description || "",
|
description: description || "",
|
||||||
address: address || "",
|
address: address || "",
|
||||||
...commonDataPayload,
|
...commonDataPayload,
|
||||||
@@ -507,7 +502,6 @@ class StationsStore {
|
|||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Reset editStationData when navigating away or after saving
|
|
||||||
resetEditStationData = () => {
|
resetEditStationData = () => {
|
||||||
this.editStationData = {
|
this.editStationData = {
|
||||||
ru: {
|
ru: {
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ import {
|
|||||||
devicesStore,
|
devicesStore,
|
||||||
Modal,
|
Modal,
|
||||||
snapshotStore,
|
snapshotStore,
|
||||||
vehicleStore, // Not directly used in this component's rendering logic anymore
|
vehicleStore,
|
||||||
} from "@shared"; // Assuming @shared exports these
|
} from "@shared";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { Button, Checkbox, Typography } from "@mui/material";
|
import { Button, Checkbox, Typography } from "@mui/material";
|
||||||
@@ -23,12 +23,10 @@ import { useNavigate } from "react-router-dom";
|
|||||||
export type ConnectedDevice = string;
|
export type ConnectedDevice = string;
|
||||||
|
|
||||||
interface Snapshot {
|
interface Snapshot {
|
||||||
ID: string; // Assuming ID is string based on usage
|
ID: string;
|
||||||
Name: string;
|
Name: string;
|
||||||
// Add other snapshot properties if needed
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- HELPER FUNCTIONS ---
|
|
||||||
const formatDate = (dateString: string | null) => {
|
const formatDate = (dateString: string | null) => {
|
||||||
if (!dateString) return "Нет данных";
|
if (!dateString) return "Нет данных";
|
||||||
try {
|
try {
|
||||||
@@ -76,12 +74,7 @@ function createData(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// This function transforms the raw device data (which includes vehicle and device_status)
|
const transformDevicesToRows = (vehicles: Vehicle[]): TableRowData[] => {
|
||||||
// into the format expected by the table. It now filters for devices that have a UUID.
|
|
||||||
const transformDevicesToRows = (
|
|
||||||
vehicles: Vehicle[]
|
|
||||||
// devices: ConnectedDevice[]
|
|
||||||
): TableRowData[] => {
|
|
||||||
return vehicles.map((vehicle) => {
|
return vehicles.map((vehicle) => {
|
||||||
const uuid = vehicle.vehicle.uuid;
|
const uuid = vehicle.vehicle.uuid;
|
||||||
if (!uuid)
|
if (!uuid)
|
||||||
@@ -115,26 +108,21 @@ export const DevicesTable = observer(() => {
|
|||||||
} = devicesStore;
|
} = devicesStore;
|
||||||
|
|
||||||
const { snapshots, getSnapshots } = snapshotStore;
|
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 { devices } = devicesStore;
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [selectedDeviceUuids, setSelectedDeviceUuids] = useState<string[]>([]);
|
const [selectedDeviceUuids, setSelectedDeviceUuids] = useState<string[]>([]);
|
||||||
|
|
||||||
// Transform the raw devices data into rows suitable for the table
|
const currentTableRows = transformDevicesToRows(vehicles.data as Vehicle[]);
|
||||||
// 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[]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
await getVehicles(); // Not strictly needed if devicesStore.devices is populated correctly by getDevices
|
await getVehicles();
|
||||||
await getDevices(); // This should fetch the combined vehicle/device_status data
|
await getDevices();
|
||||||
await getSnapshots();
|
await getSnapshots();
|
||||||
};
|
};
|
||||||
fetchData();
|
fetchData();
|
||||||
}, [getDevices, getSnapshots]); // Added dependencies
|
}, [getDevices, getSnapshots]);
|
||||||
|
|
||||||
const isAllSelected =
|
const isAllSelected =
|
||||||
currentTableRows.length > 0 &&
|
currentTableRows.length > 0 &&
|
||||||
@@ -144,7 +132,6 @@ export const DevicesTable = observer(() => {
|
|||||||
if (isAllSelected) {
|
if (isAllSelected) {
|
||||||
setSelectedDeviceUuids([]);
|
setSelectedDeviceUuids([]);
|
||||||
} else {
|
} else {
|
||||||
// Select all device UUIDs from the *currently visible and selectable* rows
|
|
||||||
setSelectedDeviceUuids(
|
setSelectedDeviceUuids(
|
||||||
currentTableRows.map((row) => row.device_uuid ?? "")
|
currentTableRows.map((row) => row.device_uuid ?? "")
|
||||||
);
|
);
|
||||||
@@ -171,14 +158,13 @@ export const DevicesTable = observer(() => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleReloadStatus = async (uuid: string) => {
|
const handleReloadStatus = async (uuid: string) => {
|
||||||
setSelectedDevice(uuid); // Sets the device in the store, useful for context elsewhere
|
setSelectedDevice(uuid);
|
||||||
try {
|
try {
|
||||||
await authInstance.post(`/devices/${uuid}/request-status`);
|
await authInstance.post(`/devices/${uuid}/request-status`);
|
||||||
await getVehicles();
|
await getVehicles();
|
||||||
await getDevices(); // Refresh devices to show updated status
|
await getDevices();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error requesting status for device ${uuid}:`, 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 {
|
try {
|
||||||
// Create an array of promises for all snapshot requests
|
|
||||||
const snapshotPromises = selectedDeviceUuids.map((deviceUuid) => {
|
const snapshotPromises = selectedDeviceUuids.map((deviceUuid) => {
|
||||||
return send(deviceUuid);
|
return send(deviceUuid);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Wait for all promises to settle (either resolve or reject)
|
|
||||||
await Promise.allSettled(snapshotPromises);
|
await Promise.allSettled(snapshotPromises);
|
||||||
|
|
||||||
// After all requests are attempted
|
await getDevices();
|
||||||
await getDevices(); // Refresh the device list
|
setSelectedDeviceUuids([]);
|
||||||
setSelectedDeviceUuids([]); // Clear the selection
|
toggleSendSnapshotModal();
|
||||||
toggleSendSnapshotModal(); // Close the modal
|
|
||||||
} catch (error) {
|
} 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);
|
console.error("Error in snapshot sending process:", error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -235,7 +215,7 @@ export const DevicesTable = observer(() => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end p-3 gap-2">
|
<div className="flex justify-end p-3 gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outlined" // Changed to outlined for distinction
|
variant="outlined"
|
||||||
onClick={handleSelectAllDevices}
|
onClick={handleSelectAllDevices}
|
||||||
size="small"
|
size="small"
|
||||||
>
|
>
|
||||||
@@ -286,7 +266,6 @@ export const DevicesTable = observer(() => {
|
|||||||
)}
|
)}
|
||||||
selected={selectedDeviceUuids.includes(row.device_uuid ?? "")}
|
selected={selectedDeviceUuids.includes(row.device_uuid ?? "")}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
// Allow clicking row to toggle checkbox, if not clicking on button
|
|
||||||
if (
|
if (
|
||||||
(event.target as HTMLElement).closest("button") === null &&
|
(event.target as HTMLElement).closest("button") === null &&
|
||||||
(event.target as HTMLElement).closest(
|
(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) {
|
if (!event.shiftKey) {
|
||||||
handleSelectDevice(
|
handleSelectDevice(
|
||||||
{
|
{
|
||||||
@@ -317,7 +295,7 @@ export const DevicesTable = observer(() => {
|
|||||||
row.device_uuid ?? ""
|
row.device_uuid ?? ""
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
} as React.ChangeEvent<HTMLInputElement>, // Simulate event
|
} as React.ChangeEvent<HTMLInputElement>,
|
||||||
row.device_uuid ?? ""
|
row.device_uuid ?? ""
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -445,7 +423,7 @@ export const DevicesTable = observer(() => {
|
|||||||
</strong>
|
</strong>
|
||||||
</Typography>
|
</Typography>
|
||||||
<div className="mt-2 flex flex-col gap-2 max-h-[300px] overflow-y-auto pr-2">
|
<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) => (
|
(snapshots as Snapshot[]).map((snapshot) => (
|
||||||
<Button
|
<Button
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
|
|||||||
@@ -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 { 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 { editSightStore } from "@shared";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
interface ImageUploadCardProps {
|
interface ImageUploadCardProps {
|
||||||
@@ -27,18 +27,9 @@ export const ImageUploadCard: React.FC<ImageUploadCardProps> = ({
|
|||||||
tooltipText,
|
tooltipText,
|
||||||
}) => {
|
}) => {
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [isDragOver, setIsDragOver] = useState(false);
|
|
||||||
const { setFileToUpload } = editSightStore;
|
const { setFileToUpload } = editSightStore;
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isDragOver) {
|
|
||||||
console.log("isDragOver");
|
|
||||||
}
|
|
||||||
}, [isDragOver]);
|
|
||||||
|
|
||||||
// --- Click to select file ---
|
|
||||||
const handleZoneClick = () => {
|
const handleZoneClick = () => {
|
||||||
// Trigger the hidden file input click
|
|
||||||
fileInputRef.current?.click();
|
fileInputRef.current?.click();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -59,28 +50,25 @@ export const ImageUploadCard: React.FC<ImageUploadCardProps> = ({
|
|||||||
toast.error("Пожалуйста, выберите изображение");
|
toast.error("Пожалуйста, выберите изображение");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Reset the input value so selecting the same file again triggers change
|
|
||||||
event.target.value = "";
|
event.target.value = "";
|
||||||
};
|
};
|
||||||
|
|
||||||
const token = localStorage.getItem("token");
|
const token = localStorage.getItem("token");
|
||||||
// --- Drag and Drop Handlers ---
|
|
||||||
const handleDragOver = (event: DragEvent<HTMLDivElement>) => {
|
const handleDragOver = (event: DragEvent<HTMLDivElement>) => {
|
||||||
event.preventDefault(); // Crucial to allow a drop
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
setIsDragOver(true);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDragLeave = (event: DragEvent<HTMLDivElement>) => {
|
const handleDragLeave = (event: DragEvent<HTMLDivElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
setIsDragOver(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDrop = async (event: DragEvent<HTMLDivElement>) => {
|
const handleDrop = async (event: DragEvent<HTMLDivElement>) => {
|
||||||
event.preventDefault(); // Crucial to allow a drop
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
setIsDragOver(false);
|
|
||||||
|
|
||||||
const files = event.dataTransfer.files;
|
const files = event.dataTransfer.files;
|
||||||
if (files && files.length > 0) {
|
if (files && files.length > 0) {
|
||||||
@@ -132,7 +120,6 @@ export const ImageUploadCard: React.FC<ImageUploadCardProps> = ({
|
|||||||
cursor: imageUrl ? "pointer" : "default",
|
cursor: imageUrl ? "pointer" : "default",
|
||||||
}}
|
}}
|
||||||
onClick={onImageClick}
|
onClick={onImageClick}
|
||||||
// Removed onClick on the main Box to avoid conflicts
|
|
||||||
>
|
>
|
||||||
{imageUrl && (
|
{imageUrl && (
|
||||||
<button
|
<button
|
||||||
@@ -165,7 +152,7 @@ export const ImageUploadCard: React.FC<ImageUploadCardProps> = ({
|
|||||||
borderRadius: 1,
|
borderRadius: 1,
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
}}
|
}}
|
||||||
onClick={handleZoneClick} // Click handler for the zone
|
onClick={handleZoneClick}
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
@@ -179,8 +166,8 @@ export const ImageUploadCard: React.FC<ImageUploadCardProps> = ({
|
|||||||
color="primary"
|
color="primary"
|
||||||
startIcon={<Plus color="white" size={18} />}
|
startIcon={<Plus color="white" size={18} />}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation(); // Prevent `handleZoneClick` from firing
|
e.stopPropagation();
|
||||||
onSelectFileClick(); // This button might trigger a different modal
|
onSelectFileClick();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Выбрать файл
|
Выбрать файл
|
||||||
@@ -191,7 +178,7 @@ export const ImageUploadCard: React.FC<ImageUploadCardProps> = ({
|
|||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
onChange={handleFileInputChange}
|
onChange={handleFileInputChange}
|
||||||
style={{ display: "none" }}
|
style={{ display: "none" }}
|
||||||
accept="image/*" // Accept only image files
|
accept="image/*"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,11 +3,9 @@ import { OrbitControls, Stage, useGLTF } from "@react-three/drei";
|
|||||||
import { useEffect, Suspense } from "react";
|
import { useEffect, Suspense } from "react";
|
||||||
import { Box, CircularProgress, Typography } from "@mui/material";
|
import { Box, CircularProgress, Typography } from "@mui/material";
|
||||||
|
|
||||||
// Утилита для очистки кеша GLTF
|
|
||||||
const clearGLTFCache = (url?: string) => {
|
const clearGLTFCache = (url?: string) => {
|
||||||
try {
|
try {
|
||||||
if (url) {
|
if (url) {
|
||||||
// Если это blob URL, очищаем его из кеша
|
|
||||||
if (url.startsWith("blob:")) {
|
if (url.startsWith("blob:")) {
|
||||||
useGLTF.clear(url);
|
useGLTF.clear(url);
|
||||||
} else {
|
} else {
|
||||||
@@ -19,29 +17,23 @@ const clearGLTFCache = (url?: string) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Утилита для проверки типа файла
|
|
||||||
const isValid3DFile = (url: string): boolean => {
|
const isValid3DFile = (url: string): boolean => {
|
||||||
try {
|
try {
|
||||||
const urlObj = new URL(url);
|
const urlObj = new URL(url);
|
||||||
const pathname = urlObj.pathname.toLowerCase();
|
const pathname = urlObj.pathname.toLowerCase();
|
||||||
const searchParams = urlObj.searchParams;
|
const searchParams = urlObj.searchParams;
|
||||||
|
|
||||||
// Проверяем расширение файла в пути
|
|
||||||
const validExtensions = [".glb", ".gltf"];
|
const validExtensions = [".glb", ".gltf"];
|
||||||
const hasValidExtension = validExtensions.some((ext) =>
|
const hasValidExtension = validExtensions.some((ext) =>
|
||||||
pathname.endsWith(ext)
|
pathname.endsWith(ext)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Проверяем параметры запроса на наличие типа файла
|
|
||||||
const fileType = searchParams.get("type") || searchParams.get("format");
|
const fileType = searchParams.get("type") || searchParams.get("format");
|
||||||
const hasValidType =
|
const hasValidType =
|
||||||
fileType && ["glb", "gltf"].includes(fileType.toLowerCase());
|
fileType && ["glb", "gltf"].includes(fileType.toLowerCase());
|
||||||
|
|
||||||
// Если это blob URL, считаем его валидным (пользователь выбрал файл)
|
|
||||||
const isBlobUrl = url.startsWith("blob:");
|
const isBlobUrl = url.startsWith("blob:");
|
||||||
|
|
||||||
// Если это URL с токеном и нет явного расширения, считаем валидным
|
|
||||||
// (предполагаем что сервер вернет правильный файл)
|
|
||||||
const hasToken = searchParams.has("token");
|
const hasToken = searchParams.has("token");
|
||||||
const isServerUrl = hasToken && !hasValidExtension;
|
const isServerUrl = hasToken && !hasValidExtension;
|
||||||
|
|
||||||
@@ -51,7 +43,7 @@ const isValid3DFile = (url: string): boolean => {
|
|||||||
return isValid;
|
return isValid;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("⚠️ isValid3DFile: Ошибка при проверке URL", error);
|
console.warn("⚠️ isValid3DFile: Ошибка при проверке URL", error);
|
||||||
// В случае ошибки парсинга URL, считаем валидным (пусть useGLTF сам разберется)
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -63,13 +55,10 @@ type ModelViewerProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const Model = ({ fileUrl }: { fileUrl: string }) => {
|
const Model = ({ fileUrl }: { fileUrl: string }) => {
|
||||||
// Очищаем кеш перед загрузкой новой модели
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Очищаем кеш для текущего URL
|
|
||||||
clearGLTFCache(fileUrl);
|
clearGLTFCache(fileUrl);
|
||||||
}, [fileUrl]);
|
}, [fileUrl]);
|
||||||
|
|
||||||
// Проверяем валидность файла перед загрузкой (только для blob URL)
|
|
||||||
if (fileUrl.startsWith("blob:") && !isValid3DFile(fileUrl)) {
|
if (fileUrl.startsWith("blob:") && !isValid3DFile(fileUrl)) {
|
||||||
console.warn("⚠️ Model: Попытка загрузить невалидный 3D файл", { fileUrl });
|
console.warn("⚠️ Model: Попытка загрузить невалидный 3D файл", { fileUrl });
|
||||||
throw new Error(`Файл не является корректной 3D моделью: ${fileUrl}`);
|
throw new Error(`Файл не является корректной 3D моделью: ${fileUrl}`);
|
||||||
@@ -114,16 +103,13 @@ export const ThreeView = ({
|
|||||||
height = "100%",
|
height = "100%",
|
||||||
width = "100%",
|
width = "100%",
|
||||||
}: ModelViewerProps) => {
|
}: ModelViewerProps) => {
|
||||||
// Проверяем валидность файла (только для blob URL)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (fileUrl.startsWith("blob:") && !isValid3DFile(fileUrl)) {
|
if (fileUrl.startsWith("blob:") && !isValid3DFile(fileUrl)) {
|
||||||
console.warn("⚠️ ThreeView: Невалидный 3D файл", { fileUrl });
|
console.warn("⚠️ ThreeView: Невалидный 3D файл", { fileUrl });
|
||||||
}
|
}
|
||||||
}, [fileUrl]);
|
}, [fileUrl]);
|
||||||
|
|
||||||
// Очищаем кеш при размонтировании и при смене URL
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Очищаем кеш сразу при монтировании компонента
|
|
||||||
clearGLTFCache(fileUrl);
|
clearGLTFCache(fileUrl);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ export class ThreeViewErrorBoundary extends Component<Props, State> {
|
|||||||
props: Props,
|
props: Props,
|
||||||
state: State
|
state: State
|
||||||
): Partial<State> | null {
|
): Partial<State> | null {
|
||||||
// Сбрасываем ошибку ТОЛЬКО при смене медиа (когда меняется ID в resetKey)
|
|
||||||
if (
|
if (
|
||||||
props.resetKey !== state.lastResetKey &&
|
props.resetKey !== state.lastResetKey &&
|
||||||
state.lastResetKey !== undefined
|
state.lastResetKey !== undefined
|
||||||
@@ -43,7 +42,6 @@ export class ThreeViewErrorBoundary extends Component<Props, State> {
|
|||||||
const oldMediaId = String(state.lastResetKey).split("-")[0];
|
const oldMediaId = String(state.lastResetKey).split("-")[0];
|
||||||
const newMediaId = String(props.resetKey).split("-")[0];
|
const newMediaId = String(props.resetKey).split("-")[0];
|
||||||
|
|
||||||
// Сбрасываем ошибку только если изменился ID медиа (пользователь выбрал другую модель)
|
|
||||||
if (oldMediaId !== newMediaId) {
|
if (oldMediaId !== newMediaId) {
|
||||||
return {
|
return {
|
||||||
hasError: false,
|
hasError: false,
|
||||||
@@ -52,9 +50,6 @@ export class ThreeViewErrorBoundary extends Component<Props, State> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Если изменился только счетчик (нажата кнопка "Попробовать снова"), обновляем lastResetKey
|
|
||||||
// но не сбрасываем ошибку автоматически - ждем результата загрузки
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
lastResetKey: props.resetKey,
|
lastResetKey: props.resetKey,
|
||||||
};
|
};
|
||||||
@@ -127,15 +122,12 @@ export class ThreeViewErrorBoundary extends Component<Props, State> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
handleReset = () => {
|
handleReset = () => {
|
||||||
// Сначала сбрасываем состояние ошибки
|
|
||||||
this.setState(
|
this.setState(
|
||||||
{
|
{
|
||||||
hasError: false,
|
hasError: false,
|
||||||
error: null,
|
error: null,
|
||||||
},
|
},
|
||||||
() => {
|
() => {
|
||||||
// После того как состояние обновилось, вызываем callback для изменения resetKey
|
|
||||||
// Это приведет к пересозданию компонента и новой попытке загрузки
|
|
||||||
this.props.onReset?.();
|
this.props.onReset?.();
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export function MediaViewer({
|
|||||||
// Используем новый cache manager для очистки кеша
|
// Используем новый cache manager для очистки кеша
|
||||||
clearMediaTransitionCache(
|
clearMediaTransitionCache(
|
||||||
previousMediaId,
|
previousMediaId,
|
||||||
media?.id || null,
|
|
||||||
media?.media_type
|
media?.media_type
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
|
||||||
// />
|
|
||||||
// </>
|
|
||||||
// );
|
|
||||||
// };
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
// @widgets/LeftWidgetTab.tsx
|
|
||||||
import { Box, Button, TextField, Paper, Typography } from "@mui/material";
|
import { Box, Button, TextField, Paper, Typography } from "@mui/material";
|
||||||
import {
|
import {
|
||||||
BackButton,
|
BackButton,
|
||||||
@@ -50,17 +49,6 @@ export const CreateLeftTab = observer(
|
|||||||
const [isSelectMediaDialogOpen, setIsSelectMediaDialogOpen] =
|
const [isSelectMediaDialogOpen, setIsSelectMediaDialogOpen] =
|
||||||
useState(false);
|
useState(false);
|
||||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = 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(() => {
|
const handleCloseArticleDialog = useCallback(() => {
|
||||||
setIsSelectArticleDialogOpen(false);
|
setIsSelectArticleDialogOpen(false);
|
||||||
|
|||||||
@@ -13,28 +13,27 @@ import {
|
|||||||
languageStore,
|
languageStore,
|
||||||
SelectArticleModal,
|
SelectArticleModal,
|
||||||
TabPanel,
|
TabPanel,
|
||||||
SelectMediaDialog, // Import
|
SelectMediaDialog,
|
||||||
UploadMediaDialog,
|
UploadMediaDialog,
|
||||||
Media, // Import
|
Media,
|
||||||
} from "@shared";
|
} from "@shared";
|
||||||
import {
|
import {
|
||||||
LanguageSwitcher,
|
LanguageSwitcher,
|
||||||
MediaArea, // Import
|
MediaArea,
|
||||||
MediaAreaForSight, // Import
|
MediaAreaForSight,
|
||||||
ReactMarkdownComponent,
|
ReactMarkdownComponent,
|
||||||
ReactMarkdownEditor,
|
ReactMarkdownEditor,
|
||||||
DeleteModal,
|
DeleteModal,
|
||||||
} from "@widgets";
|
} 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 { observer } from "mobx-react-lite";
|
||||||
import { useState, useEffect } from "react"; // Added useEffect
|
import { useState, useEffect } from "react";
|
||||||
import { MediaViewer } from "../../MediaViewer/index";
|
import { MediaViewer } from "../../MediaViewer/index";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import { authInstance } from "@shared";
|
import { authInstance } from "@shared";
|
||||||
import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd";
|
import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd";
|
||||||
|
|
||||||
type MediaItemShared = {
|
type MediaItemShared = {
|
||||||
// Define if not already available from @shared
|
|
||||||
id: string;
|
id: string;
|
||||||
filename: string;
|
filename: string;
|
||||||
media_name?: string;
|
media_name?: string;
|
||||||
@@ -52,14 +51,14 @@ export const CreateRightTab = observer(
|
|||||||
unlinkPreviewMedia,
|
unlinkPreviewMedia,
|
||||||
createLinkWithRightArticle,
|
createLinkWithRightArticle,
|
||||||
deleteRightArticleMedia,
|
deleteRightArticleMedia,
|
||||||
setFileToUpload, // From store
|
setFileToUpload,
|
||||||
setUploadMediaOpen, // From store
|
setUploadMediaOpen,
|
||||||
uploadMediaOpen, // From store
|
uploadMediaOpen,
|
||||||
unlinkRightAritcle, // Corrected spelling
|
unlinkRightAritcle,
|
||||||
deleteRightArticle,
|
deleteRightArticle,
|
||||||
linkExistingRightArticle,
|
linkExistingRightArticle,
|
||||||
createSight,
|
createSight,
|
||||||
clearCreateSight, // For resetting form
|
clearCreateSight,
|
||||||
updateRightArticles,
|
updateRightArticles,
|
||||||
} = createSightStore;
|
} = createSightStore;
|
||||||
const { language } = languageStore;
|
const { language } = languageStore;
|
||||||
@@ -78,7 +77,7 @@ export const CreateRightTab = observer(
|
|||||||
>(null);
|
>(null);
|
||||||
|
|
||||||
const [previewMedia, setPreviewMedia] = useState<Media | null>(null);
|
const [previewMedia, setPreviewMedia] = useState<Media | null>(null);
|
||||||
// Reset activeArticleIndex if language changes and index is out of bounds
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (sight.preview_media) {
|
if (sight.preview_media) {
|
||||||
const fetchMedia = async () => {
|
const fetchMedia = async () => {
|
||||||
@@ -97,7 +96,7 @@ export const CreateRightTab = observer(
|
|||||||
activeArticleIndex >= sight[language].right.length
|
activeArticleIndex >= sight[language].right.length
|
||||||
) {
|
) {
|
||||||
setActiveArticleIndex(null);
|
setActiveArticleIndex(null);
|
||||||
setType("media"); // Default back to media preview if selected article disappears
|
setType("media");
|
||||||
}
|
}
|
||||||
}, [language, sight[language].right, activeArticleIndex]);
|
}, [language, sight[language].right, activeArticleIndex]);
|
||||||
|
|
||||||
@@ -113,10 +112,9 @@ export const CreateRightTab = observer(
|
|||||||
try {
|
try {
|
||||||
await createSight(language);
|
await createSight(language);
|
||||||
toast.success("Достопримечательность успешно создана!");
|
toast.success("Достопримечательность успешно создана!");
|
||||||
clearCreateSight(); // Reset form
|
clearCreateSight();
|
||||||
setActiveArticleIndex(null);
|
setActiveArticleIndex(null);
|
||||||
setType("media");
|
setType("media");
|
||||||
// Potentially navigate away: history.push('/sights-list');
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to save sight:", error);
|
console.error("Failed to save sight:", error);
|
||||||
toast.error("Ошибка при создании достопримечательности.");
|
toast.error("Ошибка при создании достопримечательности.");
|
||||||
@@ -132,7 +130,7 @@ export const CreateRightTab = observer(
|
|||||||
handleCloseMenu();
|
handleCloseMenu();
|
||||||
try {
|
try {
|
||||||
const newArticleId = await createNewRightArticle();
|
const newArticleId = await createNewRightArticle();
|
||||||
// Automatically select the new article if ID is returned
|
|
||||||
const newIndex = sight[language].right.findIndex(
|
const newIndex = sight[language].right.findIndex(
|
||||||
(a) => a.id === newArticleId
|
(a) => a.id === newArticleId
|
||||||
);
|
);
|
||||||
@@ -140,7 +138,6 @@ export const CreateRightTab = observer(
|
|||||||
setActiveArticleIndex(newIndex);
|
setActiveArticleIndex(newIndex);
|
||||||
setType("article");
|
setType("article");
|
||||||
} else {
|
} else {
|
||||||
// Fallback if findIndex fails (should not happen if store updates correctly)
|
|
||||||
setActiveArticleIndex(sight[language].right.length - 1);
|
setActiveArticleIndex(sight[language].right.length - 1);
|
||||||
setType("article");
|
setType("article");
|
||||||
}
|
}
|
||||||
@@ -156,7 +153,7 @@ export const CreateRightTab = observer(
|
|||||||
const linkedArticleId = await linkExistingRightArticle(
|
const linkedArticleId = await linkExistingRightArticle(
|
||||||
selectedArticleId
|
selectedArticleId
|
||||||
);
|
);
|
||||||
setSelectArticleDialogOpen(false); // Close dialog
|
setSelectArticleDialogOpen(false);
|
||||||
const newIndex = sight[language].right.findIndex(
|
const newIndex = sight[language].right.findIndex(
|
||||||
(a) => a.id === linkedArticleId
|
(a) => a.id === linkedArticleId
|
||||||
);
|
);
|
||||||
@@ -174,7 +171,6 @@ export const CreateRightTab = observer(
|
|||||||
? sight[language].right[activeArticleIndex]
|
? sight[language].right[activeArticleIndex]
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
// Media Handling for Dialogs
|
|
||||||
const handleOpenUploadMedia = () => {
|
const handleOpenUploadMedia = () => {
|
||||||
setUploadMediaOpen(true);
|
setUploadMediaOpen(true);
|
||||||
};
|
};
|
||||||
@@ -203,7 +199,6 @@ export const CreateRightTab = observer(
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleMediaUploaded = async (media: MediaItemShared) => {
|
const handleMediaUploaded = async (media: MediaItemShared) => {
|
||||||
// After UploadMediaDialog finishes
|
|
||||||
setUploadMediaOpen(false);
|
setUploadMediaOpen(false);
|
||||||
setFileToUpload(null);
|
setFileToUpload(null);
|
||||||
if (mediaTarget === "sightPreview") {
|
if (mediaTarget === "sightPreview") {
|
||||||
@@ -211,36 +206,25 @@ export const CreateRightTab = observer(
|
|||||||
} else if (mediaTarget === "rightArticle" && currentRightArticle) {
|
} else if (mediaTarget === "rightArticle" && currentRightArticle) {
|
||||||
await createLinkWithRightArticle(media, currentRightArticle.id);
|
await createLinkWithRightArticle(media, currentRightArticle.id);
|
||||||
}
|
}
|
||||||
setMediaTarget(null); // Reset target
|
setMediaTarget(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDragEnd = (result: any) => {
|
const handleDragEnd = (result: any) => {
|
||||||
const { source, destination } = result;
|
const { source, destination } = result;
|
||||||
|
|
||||||
// 1. Guard clause: If dropped outside any droppable area, do nothing.
|
|
||||||
if (!destination) return;
|
if (!destination) return;
|
||||||
|
|
||||||
// Extract source and destination indices
|
|
||||||
const sourceIndex = source.index;
|
const sourceIndex = source.index;
|
||||||
const destinationIndex = destination.index;
|
const destinationIndex = destination.index;
|
||||||
|
|
||||||
// 2. Guard clause: If dropped in the same position, do nothing.
|
|
||||||
if (sourceIndex === destinationIndex) return;
|
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];
|
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);
|
const [movedArticle] = newRightArticles.splice(sourceIndex, 1);
|
||||||
|
|
||||||
// - Insert the moved article into its new position.
|
|
||||||
newRightArticles.splice(destinationIndex, 0, movedArticle);
|
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);
|
updateRightArticles(newRightArticles);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -254,7 +238,7 @@ export const CreateRightTab = observer(
|
|||||||
height: "100%",
|
height: "100%",
|
||||||
minHeight: "calc(100vh - 200px)",
|
minHeight: "calc(100vh - 200px)",
|
||||||
gap: 2,
|
gap: 2,
|
||||||
paddingBottom: "70px", // Space for the save button
|
paddingBottom: "70px",
|
||||||
position: "relative",
|
position: "relative",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -264,7 +248,6 @@ export const CreateRightTab = observer(
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Box sx={{ display: "flex", flexGrow: 1, gap: 2.5 }}>
|
<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="flex flex-col w-[75%] gap-2">
|
||||||
<Box className="w-full flex 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">
|
<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
|
<Box
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setType("media");
|
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 ${
|
className={`w-full p-4 rounded-2xl cursor-pointer text-sm hover:bg-gray-300 transition-all duration-300 ${
|
||||||
type === "media"
|
type === "media"
|
||||||
@@ -364,7 +346,6 @@ export const CreateRightTab = observer(
|
|||||||
</Menu>
|
</Menu>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Main content area: Article Editor or Sight Media Preview */}
|
|
||||||
{type === "article" && currentRightArticle ? (
|
{type === "article" && currentRightArticle ? (
|
||||||
<Box className="w-[80%] border border-gray-300 p-3 flex flex-col gap-2 overflow-hidden">
|
<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">
|
<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} />}
|
startIcon={<Unlink color="white" size={18} />}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (currentRightArticle) {
|
if (currentRightArticle) {
|
||||||
unlinkRightAritcle(currentRightArticle.id); // Corrected function name
|
unlinkRightAritcle(currentRightArticle.id);
|
||||||
setActiveArticleIndex(null);
|
setActiveArticleIndex(null);
|
||||||
setType("media");
|
setType("media");
|
||||||
}
|
}
|
||||||
@@ -435,7 +416,7 @@ export const CreateRightTab = observer(
|
|||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
<MediaArea
|
<MediaArea
|
||||||
articleId={currentRightArticle.id} // Needs a real ID
|
articleId={currentRightArticle.id}
|
||||||
mediaIds={currentRightArticle.media || []}
|
mediaIds={currentRightArticle.media || []}
|
||||||
onFilesDrop={(files) => {
|
onFilesDrop={(files) => {
|
||||||
if (files.length > 0) {
|
if (files.length > 0) {
|
||||||
@@ -507,7 +488,6 @@ export const CreateRightTab = observer(
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Right Column: Live Preview */}
|
|
||||||
<Box className="w-[25%] mr-10">
|
<Box className="w-[25%] mr-10">
|
||||||
{type === "article" && activeArticleIndex !== null && (
|
{type === "article" && activeArticleIndex !== null && (
|
||||||
<Paper
|
<Paper
|
||||||
@@ -662,12 +642,11 @@ export const CreateRightTab = observer(
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Sticky Save Button Footer */}
|
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
bottom: "-20px",
|
bottom: "-20px",
|
||||||
left: 0, // ensure it spans from left
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
padding: 2,
|
padding: 2,
|
||||||
backgroundColor: "background.paper",
|
backgroundColor: "background.paper",
|
||||||
@@ -689,19 +668,17 @@ export const CreateRightTab = observer(
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Modals */}
|
|
||||||
<SelectArticleModal
|
<SelectArticleModal
|
||||||
open={selectArticleDialogOpen}
|
open={selectArticleDialogOpen}
|
||||||
onClose={() => setSelectArticleDialogOpen(false)}
|
onClose={() => setSelectArticleDialogOpen(false)}
|
||||||
onSelectArticle={handleSelectExistingArticleAndLink}
|
onSelectArticle={handleSelectExistingArticleAndLink}
|
||||||
// Pass IDs of already linked/added right articles to exclude them from selection
|
|
||||||
linkedArticleIds={sight[language].right.map((article) => article.id)}
|
linkedArticleIds={sight[language].right.map((article) => article.id)}
|
||||||
/>
|
/>
|
||||||
<UploadMediaDialog
|
<UploadMediaDialog
|
||||||
open={uploadMediaOpen} // From store
|
open={uploadMediaOpen}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setUploadMediaOpen(false);
|
setUploadMediaOpen(false);
|
||||||
setFileToUpload(null); // Clear file if dialog is closed without upload
|
setFileToUpload(null);
|
||||||
setMediaTarget(null);
|
setMediaTarget(null);
|
||||||
}}
|
}}
|
||||||
contextObjectName={sight[language].name}
|
contextObjectName={sight[language].name}
|
||||||
@@ -712,7 +689,7 @@ export const CreateRightTab = observer(
|
|||||||
? sight[language].right[activeArticleIndex].heading
|
? sight[language].right[activeArticleIndex].heading
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
afterUpload={handleMediaUploaded} // This will use the mediaTarget
|
afterUpload={handleMediaUploaded}
|
||||||
/>
|
/>
|
||||||
<SelectMediaDialog
|
<SelectMediaDialog
|
||||||
open={isSelectMediaDialogOpen}
|
open={isSelectMediaDialogOpen}
|
||||||
|
|||||||
@@ -37,8 +37,8 @@ import { useEffect, useState } from "react";
|
|||||||
|
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
|
|
||||||
// Компонент предупреждающего окна (перенесен сюда)
|
|
||||||
import { SaveWithoutCityAgree } from "@widgets";
|
import { SaveWithoutCityAgree } from "@widgets";
|
||||||
|
import { LinkedStations } from "@pages";
|
||||||
|
|
||||||
export const InformationTab = observer(
|
export const InformationTab = observer(
|
||||||
({ value, index }: { value: number; index: number }) => {
|
({ value, index }: { value: number; index: number }) => {
|
||||||
@@ -62,7 +62,7 @@ export const InformationTab = observer(
|
|||||||
"thumbnail" | "watermark_lu" | "watermark_rd" | "video_preview" | null
|
"thumbnail" | "watermark_lu" | "watermark_rd" | "video_preview" | null
|
||||||
>(null);
|
>(null);
|
||||||
const { cities } = cityStore;
|
const { cities } = cityStore;
|
||||||
// НОВОЕ СОСТОЯНИЕ ДЛЯ ПРЕДУПРЕЖДАЮЩЕГО ОКНА
|
|
||||||
const [isSaveWarningOpen, setIsSaveWarningOpen] = useState(false);
|
const [isSaveWarningOpen, setIsSaveWarningOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {}, [hardcodeType]);
|
useEffect(() => {}, [hardcodeType]);
|
||||||
@@ -119,16 +119,14 @@ export const InformationTab = observer(
|
|||||||
updateSightInfo(language, content, common);
|
updateSightInfo(language, content, common);
|
||||||
};
|
};
|
||||||
|
|
||||||
// НОВАЯ ФУНКЦИЯ: Фактическое сохранение (вызывается после подтверждения)
|
|
||||||
const executeSave = async () => {
|
const executeSave = async () => {
|
||||||
await updateSight();
|
await updateSight();
|
||||||
toast.success("Достопримечательность сохранена");
|
toast.success("Достопримечательность сохранена");
|
||||||
};
|
};
|
||||||
|
|
||||||
// ОБНОВЛЕННАЯ ФУНКЦИЯ: Проверка и вызов окна или сохранения
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
const isCityMissing = !sight.common.city_id;
|
const isCityMissing = !sight.common.city_id;
|
||||||
// Проверяем названия на всех языках
|
|
||||||
const isNameMissing = !sight.ru.name || !sight.en.name || !sight.zh.name;
|
const isNameMissing = !sight.ru.name || !sight.en.name || !sight.zh.name;
|
||||||
|
|
||||||
if (isCityMissing || isNameMissing) {
|
if (isCityMissing || isNameMissing) {
|
||||||
@@ -139,13 +137,11 @@ export const InformationTab = observer(
|
|||||||
await executeSave();
|
await executeSave();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Обработчик "Да" в предупреждающем окне
|
|
||||||
const handleConfirmSave = async () => {
|
const handleConfirmSave = async () => {
|
||||||
setIsSaveWarningOpen(false);
|
setIsSaveWarningOpen(false);
|
||||||
await executeSave();
|
await executeSave();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Обработчик "Нет" в предупреждающем окне
|
|
||||||
const handleCancelSave = () => {
|
const handleCancelSave = () => {
|
||||||
setIsSaveWarningOpen(false);
|
setIsSaveWarningOpen(false);
|
||||||
};
|
};
|
||||||
@@ -275,6 +271,16 @@ export const InformationTab = observer(
|
|||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ width: "80%" }}>
|
||||||
|
{sight.common.id !== 0 && (
|
||||||
|
<LinkedStations
|
||||||
|
parentId={sight.common.id}
|
||||||
|
fields={[{ label: "Название", data: "name" }]}
|
||||||
|
type="edit"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
@@ -431,7 +437,7 @@ export const InformationTab = observer(
|
|||||||
variant="contained"
|
variant="contained"
|
||||||
color="success"
|
color="success"
|
||||||
startIcon={<Save color="white" size={18} />}
|
startIcon={<Save color="white" size={18} />}
|
||||||
onClick={handleSave} // Используем новую функцию-обработчик
|
onClick={handleSave}
|
||||||
>
|
>
|
||||||
Сохранить
|
Сохранить
|
||||||
</Button>
|
</Button>
|
||||||
@@ -538,7 +544,6 @@ export const InformationTab = observer(
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ИНТЕГРИРОВАННОЕ ПРЕДУПРЕЖДАЮЩЕЕ ОКНО */}
|
|
||||||
{isSaveWarningOpen && (
|
{isSaveWarningOpen && (
|
||||||
<SaveWithoutCityAgree
|
<SaveWithoutCityAgree
|
||||||
blocker={{
|
blocker={{
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ export const RightWidgetTab = observer(
|
|||||||
try {
|
try {
|
||||||
const newArticleId = await createNewRightArticle();
|
const newArticleId = await createNewRightArticle();
|
||||||
handleClose();
|
handleClose();
|
||||||
// Automatically select the newly created article
|
|
||||||
const newIndex = sight[language].right.findIndex(
|
const newIndex = sight[language].right.findIndex(
|
||||||
(article) => article.id === newArticleId
|
(article) => article.id === newArticleId
|
||||||
);
|
);
|
||||||
@@ -144,7 +144,7 @@ export const RightWidgetTab = observer(
|
|||||||
try {
|
try {
|
||||||
const linkedArticleId = await linkArticle(id);
|
const linkedArticleId = await linkArticle(id);
|
||||||
handleCloseSelectModal();
|
handleCloseSelectModal();
|
||||||
// Automatically select the newly linked article
|
|
||||||
const newIndex = sight[language].right.findIndex(
|
const newIndex = sight[language].right.findIndex(
|
||||||
(article) => article.id === linkedArticleId
|
(article) => article.id === linkedArticleId
|
||||||
);
|
);
|
||||||
@@ -177,30 +177,19 @@ export const RightWidgetTab = observer(
|
|||||||
const handleDragEnd = (result: DropResult) => {
|
const handleDragEnd = (result: DropResult) => {
|
||||||
const { source, destination } = result;
|
const { source, destination } = result;
|
||||||
|
|
||||||
// 1. Guard clause: If dropped outside any droppable area, do nothing.
|
|
||||||
if (!destination) return;
|
if (!destination) return;
|
||||||
|
|
||||||
// Extract source and destination indices
|
|
||||||
const sourceIndex = source.index;
|
const sourceIndex = source.index;
|
||||||
const destinationIndex = destination.index;
|
const destinationIndex = destination.index;
|
||||||
|
|
||||||
// 2. Guard clause: If dropped in the same position, do nothing.
|
|
||||||
if (sourceIndex === destinationIndex) return;
|
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];
|
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);
|
const [movedArticle] = newRightArticles.splice(sourceIndex, 1);
|
||||||
|
|
||||||
// - Insert the moved article into its new position.
|
|
||||||
newRightArticles.splice(destinationIndex, 0, movedArticle);
|
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);
|
updateRightArticles(newRightArticles);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ interface VideoPreviewCardProps {
|
|||||||
onDeleteVideoClick: () => void;
|
onDeleteVideoClick: () => void;
|
||||||
onSelectVideoClick: (file?: File) => void;
|
onSelectVideoClick: (file?: File) => void;
|
||||||
tooltipText?: string;
|
tooltipText?: string;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const VideoPreviewCard: React.FC<VideoPreviewCardProps> = ({
|
export const VideoPreviewCard: React.FC<VideoPreviewCardProps> = ({
|
||||||
@@ -20,15 +21,15 @@ export const VideoPreviewCard: React.FC<VideoPreviewCardProps> = ({
|
|||||||
onDeleteVideoClick,
|
onDeleteVideoClick,
|
||||||
onSelectVideoClick,
|
onSelectVideoClick,
|
||||||
tooltipText,
|
tooltipText,
|
||||||
|
className,
|
||||||
}) => {
|
}) => {
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [isDragOver, setIsDragOver] = useState(false);
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
const token = localStorage.getItem("token");
|
const token = localStorage.getItem("token");
|
||||||
|
|
||||||
useEffect(() => {}, [isDragOver]);
|
useEffect(() => {}, [isDragOver]);
|
||||||
// --- Click to select file ---
|
|
||||||
const handleZoneClick = () => {
|
const handleZoneClick = () => {
|
||||||
// Trigger the hidden file input click
|
|
||||||
fileInputRef.current?.click();
|
fileInputRef.current?.click();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -38,19 +39,17 @@ export const VideoPreviewCard: React.FC<VideoPreviewCardProps> = ({
|
|||||||
const file = event.target.files?.[0];
|
const file = event.target.files?.[0];
|
||||||
if (file) {
|
if (file) {
|
||||||
if (file.type.startsWith("video/")) {
|
if (file.type.startsWith("video/")) {
|
||||||
// Открываем диалог загрузки медиа с файлом видео
|
|
||||||
onSelectVideoClick(file);
|
onSelectVideoClick(file);
|
||||||
} else {
|
} else {
|
||||||
toast.error("Пожалуйста, выберите видео файл");
|
toast.error("Пожалуйста, выберите видео файл");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Reset the input value so selecting the same file again triggers change
|
|
||||||
event.target.value = "";
|
event.target.value = "";
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Drag and Drop Handlers ---
|
|
||||||
const handleDragOver = (event: DragEvent<HTMLDivElement>) => {
|
const handleDragOver = (event: DragEvent<HTMLDivElement>) => {
|
||||||
event.preventDefault(); // Crucial to allow a drop
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
setIsDragOver(true);
|
setIsDragOver(true);
|
||||||
};
|
};
|
||||||
@@ -62,7 +61,7 @@ export const VideoPreviewCard: React.FC<VideoPreviewCardProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDrop = async (event: DragEvent<HTMLDivElement>) => {
|
const handleDrop = async (event: DragEvent<HTMLDivElement>) => {
|
||||||
event.preventDefault(); // Crucial to allow a drop
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
setIsDragOver(false);
|
setIsDragOver(false);
|
||||||
|
|
||||||
@@ -70,7 +69,6 @@ export const VideoPreviewCard: React.FC<VideoPreviewCardProps> = ({
|
|||||||
if (files && files.length > 0) {
|
if (files && files.length > 0) {
|
||||||
const file = files[0];
|
const file = files[0];
|
||||||
if (file.type.startsWith("video/")) {
|
if (file.type.startsWith("video/")) {
|
||||||
// Открываем диалог загрузки медиа с файлом видео
|
|
||||||
onSelectVideoClick(file);
|
onSelectVideoClick(file);
|
||||||
} else {
|
} else {
|
||||||
toast.error("Пожалуйста, выберите видео файл");
|
toast.error("Пожалуйста, выберите видео файл");
|
||||||
@@ -89,7 +87,10 @@ export const VideoPreviewCard: React.FC<VideoPreviewCardProps> = ({
|
|||||||
gap: 1,
|
gap: 1,
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 150,
|
minWidth: 150,
|
||||||
|
width: "min-content",
|
||||||
|
mx: "auto",
|
||||||
}}
|
}}
|
||||||
|
className={className}
|
||||||
>
|
>
|
||||||
<Box sx={{ display: "flex", alignItems: "center" }}>
|
<Box sx={{ display: "flex", alignItems: "center" }}>
|
||||||
<Typography variant="subtitle2" gutterBottom sx={{ mb: 0, mr: 0.5 }}>
|
<Typography variant="subtitle2" gutterBottom sx={{ mb: 0, mr: 0.5 }}>
|
||||||
@@ -127,7 +128,10 @@ export const VideoPreviewCard: React.FC<VideoPreviewCardProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{videoId ? (
|
{videoId ? (
|
||||||
<Box sx={{ position: "relative", width: "100%", height: "100%" }}>
|
<Box
|
||||||
|
sx={{ position: "relative", width: "100%", height: "100%" }}
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
<video
|
<video
|
||||||
src={`${
|
src={`${
|
||||||
import.meta.env.VITE_KRBL_MEDIA
|
import.meta.env.VITE_KRBL_MEDIA
|
||||||
@@ -167,7 +171,7 @@ export const VideoPreviewCard: React.FC<VideoPreviewCardProps> = ({
|
|||||||
borderRadius: 1,
|
borderRadius: 1,
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
}}
|
}}
|
||||||
onClick={handleZoneClick} // Click handler for the zone
|
onClick={handleZoneClick}
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
@@ -181,8 +185,8 @@ export const VideoPreviewCard: React.FC<VideoPreviewCardProps> = ({
|
|||||||
color="primary"
|
color="primary"
|
||||||
startIcon={<Plus color="white" size={18} />}
|
startIcon={<Plus color="white" size={18} />}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation(); // Prevent `handleZoneClick` from firing
|
e.stopPropagation();
|
||||||
onSelectVideoClick(); // This button triggers the media selection dialog
|
onSelectVideoClick();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Выбрать файл
|
Выбрать файл
|
||||||
@@ -193,7 +197,7 @@ export const VideoPreviewCard: React.FC<VideoPreviewCardProps> = ({
|
|||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
onChange={handleFileInputChange}
|
onChange={handleFileInputChange}
|
||||||
style={{ display: "none" }}
|
style={{ display: "none" }}
|
||||||
accept="video/*" // Accept only video files
|
accept="video/*"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -3,56 +3,6 @@ import react from "@vitejs/plugin-react";
|
|||||||
import tailwindcss from "@tailwindcss/vite";
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
|
||||||
type ManualChunksFn = (id: string, api: { getModuleIds: () => Iterable<string> }) => string | undefined;
|
|
||||||
|
|
||||||
const manualChunks: ManualChunksFn = (id) => {
|
|
||||||
if (id.includes('node_modules')) {
|
|
||||||
|
|
||||||
if (
|
|
||||||
id.includes('three.') ||
|
|
||||||
id.includes('@react-three') ||
|
|
||||||
id.includes('ol/') ||
|
|
||||||
id.includes('mapbox-gl') ||
|
|
||||||
id.includes('@babel/runtime')
|
|
||||||
) {
|
|
||||||
return 'vendor-3d-maps';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (id.includes('codemirror') || id.includes('react-codemirror2')) {
|
|
||||||
return 'vendor-codemirror';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (id.includes('hls.js')) {
|
|
||||||
return 'vendor-hls';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (id.includes('pixi.js')) {
|
|
||||||
return 'vendor-pixijs';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (id.includes('@mui/material') || id.includes('@mui/icons-material') || id.includes('@mui/x-data-grid')) {
|
|
||||||
return 'vendor-mui-core';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (id.includes('/react/') || id.includes('/react-dom/')) {
|
|
||||||
return 'vendor-react-core';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (id.includes('react-router') || id.includes('history')) {
|
|
||||||
return 'vendor-router';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'vendor-common-remainder';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (id.includes('src/pages/')) {
|
|
||||||
const pathParts = id.split('src/pages/');
|
|
||||||
if (pathParts.length > 1) {
|
|
||||||
return 'page-' + pathParts[1].split('/')[0].toLowerCase();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [
|
plugins: [
|
||||||
react(),
|
react(),
|
||||||
@@ -70,16 +20,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
|
|
||||||
build: {
|
build: {
|
||||||
chunkSizeWarningLimit: 2000,
|
chunkSizeWarningLimit: 5000,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
rollupOptions: {
|
|
||||||
output: {
|
|
||||||
manualChunks,
|
|
||||||
|
|
||||||
entryFileNames: `assets/[name]-[hash].js`,
|
|
||||||
chunkFileNames: `assets/[name]-[hash].js`,
|
|
||||||
assetFileNames: `assets/[name]-[hash].[ext]`,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}) as UserConfigExport;
|
|
||||||
Reference in New Issue
Block a user