added more types for media
@ -1,7 +1,7 @@
|
|||||||
# This Dockerfile uses `serve` npm package to serve the static files with node process.
|
# This Dockerfile uses `serve` npm package to serve the static files with node process.
|
||||||
# You can find the Dockerfile for nginx in the following link:
|
# You can find the Dockerfile for nginx in the following link:
|
||||||
# https://github.com/refinedev/dockerfiles/blob/main/vite/Dockerfile.nginx
|
# https://github.com/refinedev/dockerfiles/blob/main/vite/Dockerfile.nginx
|
||||||
FROM refinedev/node:18 AS base
|
FROM refinedev/node:20 AS base
|
||||||
|
|
||||||
FROM base as deps
|
FROM base as deps
|
||||||
|
|
||||||
|
5
compose.yaml
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
services:
|
||||||
|
refine:
|
||||||
|
image: white-nights:latest
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
12782
package-lock.json
generated
Normal file
@ -11,7 +11,7 @@
|
|||||||
"@mui/lab": "^6.0.0-beta.14",
|
"@mui/lab": "^6.0.0-beta.14",
|
||||||
"@mui/material": "^6.1.7",
|
"@mui/material": "^6.1.7",
|
||||||
"@mui/x-data-grid": "^7.22.2",
|
"@mui/x-data-grid": "^7.22.2",
|
||||||
"@photo-sphere-viewer/core": "^5.13.1",
|
"@photo-sphere-viewer/core": "^5.13.2",
|
||||||
"@react-three/drei": "^10.0.6",
|
"@react-three/drei": "^10.0.6",
|
||||||
"@react-three/fiber": "^9.1.2",
|
"@react-three/fiber": "^9.1.2",
|
||||||
"@refinedev/cli": "^2.16.21",
|
"@refinedev/cli": "^2.16.21",
|
||||||
@ -43,7 +43,7 @@
|
|||||||
"react-i18next": "^15.4.1",
|
"react-i18next": "^15.4.1",
|
||||||
"react-intl": "^7.1.10",
|
"react-intl": "^7.1.10",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-photo-sphere-viewer": "^6.2.2",
|
"react-photo-sphere-viewer": "^6.2.3",
|
||||||
"react-router": "^7.0.2",
|
"react-router": "^7.0.2",
|
||||||
"react-simple-maps": "^3.0.0",
|
"react-simple-maps": "^3.0.0",
|
||||||
"react-simplemde-editor": "^5.2.0",
|
"react-simplemde-editor": "^5.2.0",
|
||||||
|
8638
pnpm-lock.yaml
296
src/App.tsx
@ -1,6 +1,5 @@
|
|||||||
import { Refine, Authenticated } from "@refinedev/core";
|
import { Refine, Authenticated } from "@refinedev/core";
|
||||||
import { DevtoolsPanel, DevtoolsProvider } from "@refinedev/devtools";
|
import { DevtoolsPanel, DevtoolsProvider } from "@refinedev/devtools";
|
||||||
import { RefineKbar, RefineKbarProvider } from "@refinedev/kbar";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ErrorComponent,
|
ErrorComponent,
|
||||||
@ -12,7 +11,7 @@ import {
|
|||||||
import { customDataProvider } from "./providers/data";
|
import { customDataProvider } from "./providers/data";
|
||||||
import CssBaseline from "@mui/material/CssBaseline";
|
import CssBaseline from "@mui/material/CssBaseline";
|
||||||
import GlobalStyles from "@mui/material/GlobalStyles";
|
import GlobalStyles from "@mui/material/GlobalStyles";
|
||||||
import { BrowserRouter, Route, Routes, Outlet } from "react-router";
|
import { BrowserRouter, Route, Routes, Outlet, HashRouter } from "react-router";
|
||||||
import routerBindings, {
|
import routerBindings, {
|
||||||
NavigateToResource,
|
NavigateToResource,
|
||||||
CatchAllNavigate,
|
CatchAllNavigate,
|
||||||
@ -75,158 +74,154 @@ import {
|
|||||||
} from "./components/ui/Icons";
|
} from "./components/ui/Icons";
|
||||||
import SidebarTitle from "./components/ui/SidebarTitle";
|
import SidebarTitle from "./components/ui/SidebarTitle";
|
||||||
import { AdminOnly } from "./components/AdminOnly";
|
import { AdminOnly } from "./components/AdminOnly";
|
||||||
import { Dashboard } from "./preview/widgets/dashboard/Dashboard";
|
|
||||||
import { QueryClient } from "@tanstack/react-query";
|
|
||||||
import { QueryClientProvider } from "@tanstack/react-query";
|
|
||||||
import { LoadingProvider } from "@mt/utils";
|
|
||||||
import { RoutePreview } from "./preview/components/route-preview/components/RoutePreview";
|
|
||||||
|
|
||||||
const queryClient = new QueryClient();
|
import { LoadingProvider } from "@mt/utils";
|
||||||
|
import { KBarProvider, RefineKbar } from "@refinedev/kbar";
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<LoadingProvider>
|
||||||
<LoadingProvider>
|
<HashRouter>
|
||||||
<BrowserRouter>
|
<ColorModeContextProvider>
|
||||||
<ColorModeContextProvider>
|
<CssBaseline />
|
||||||
<CssBaseline />
|
<GlobalStyles styles={{ html: { WebkitFontSmoothing: "auto" } }} />
|
||||||
<GlobalStyles styles={{ html: { WebkitFontSmoothing: "auto" } }} />
|
<RefineSnackbarProvider>
|
||||||
<RefineSnackbarProvider>
|
<DevtoolsProvider>
|
||||||
<DevtoolsProvider>
|
<Refine
|
||||||
<Refine
|
dataProvider={customDataProvider}
|
||||||
dataProvider={customDataProvider}
|
notificationProvider={useNotificationProvider}
|
||||||
notificationProvider={useNotificationProvider}
|
routerProvider={routerBindings}
|
||||||
routerProvider={routerBindings}
|
authProvider={authProvider}
|
||||||
authProvider={authProvider}
|
i18nProvider={i18nProvider}
|
||||||
i18nProvider={i18nProvider}
|
resources={[
|
||||||
resources={[
|
{
|
||||||
{
|
name: "country",
|
||||||
name: "country",
|
list: "/country",
|
||||||
list: "/country",
|
create: "/country/create",
|
||||||
create: "/country/create",
|
edit: "/country/edit/:id",
|
||||||
edit: "/country/edit/:id",
|
show: "/country/show/:id",
|
||||||
show: "/country/show/:id",
|
meta: {
|
||||||
meta: {
|
canDelete: true,
|
||||||
canDelete: true,
|
label: "Страны",
|
||||||
label: "Страны",
|
icon: <CountryIcon />,
|
||||||
icon: <CountryIcon />,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
},
|
||||||
name: "city",
|
{
|
||||||
list: "/city",
|
name: "city",
|
||||||
create: "/city/create",
|
list: "/city",
|
||||||
edit: "/city/edit/:id",
|
create: "/city/create",
|
||||||
show: "/city/show/:id",
|
edit: "/city/edit/:id",
|
||||||
meta: {
|
show: "/city/show/:id",
|
||||||
canDelete: true,
|
meta: {
|
||||||
label: "Города",
|
canDelete: true,
|
||||||
icon: <CityIcon />,
|
label: "Города",
|
||||||
},
|
icon: <CityIcon />,
|
||||||
},
|
},
|
||||||
{
|
},
|
||||||
name: "carrier",
|
{
|
||||||
list: "/carrier",
|
name: "carrier",
|
||||||
create: "/carrier/create",
|
list: "/carrier",
|
||||||
edit: "/carrier/edit/:id",
|
create: "/carrier/create",
|
||||||
show: "/carrier/show/:id",
|
edit: "/carrier/edit/:id",
|
||||||
meta: {
|
show: "/carrier/show/:id",
|
||||||
canDelete: true,
|
meta: {
|
||||||
label: "Перевозчики",
|
canDelete: true,
|
||||||
icon: <CarrierIcon />,
|
label: "Перевозчики",
|
||||||
},
|
icon: <CarrierIcon />,
|
||||||
},
|
},
|
||||||
{
|
},
|
||||||
name: "media",
|
{
|
||||||
list: "/media",
|
name: "media",
|
||||||
create: "/media/create",
|
list: "/media",
|
||||||
edit: "/media/edit/:id",
|
create: "/media/create",
|
||||||
show: "/media/show/:id",
|
edit: "/media/edit/:id",
|
||||||
meta: {
|
show: "/media/show/:id",
|
||||||
canDelete: true,
|
meta: {
|
||||||
label: "Медиа",
|
canDelete: true,
|
||||||
icon: <MediaIcon />,
|
label: "Медиа",
|
||||||
},
|
icon: <MediaIcon />,
|
||||||
},
|
},
|
||||||
{
|
},
|
||||||
name: "article",
|
{
|
||||||
list: "/article",
|
name: "article",
|
||||||
create: "/article/create",
|
list: "/article",
|
||||||
edit: "/article/edit/:id",
|
create: "/article/create",
|
||||||
show: "/article/show/:id",
|
edit: "/article/edit/:id",
|
||||||
meta: {
|
show: "/article/show/:id",
|
||||||
canDelete: true,
|
meta: {
|
||||||
label: "Статьи",
|
canDelete: true,
|
||||||
icon: <ArticleIcon />,
|
label: "Статьи",
|
||||||
},
|
icon: <ArticleIcon />,
|
||||||
},
|
},
|
||||||
{
|
},
|
||||||
name: "sight",
|
{
|
||||||
list: "/sight",
|
name: "sight",
|
||||||
create: "/sight/create",
|
list: "/sight",
|
||||||
edit: "/sight/edit/:id",
|
create: "/sight/create",
|
||||||
show: "/sight/show/:id",
|
edit: "/sight/edit/:id",
|
||||||
meta: {
|
show: "/sight/show/:id",
|
||||||
canDelete: true,
|
meta: {
|
||||||
label: "Достопримечательности",
|
canDelete: true,
|
||||||
icon: <SightIcon />,
|
label: "Достопримечательности",
|
||||||
},
|
icon: <SightIcon />,
|
||||||
},
|
},
|
||||||
{
|
},
|
||||||
name: "station",
|
{
|
||||||
list: "/station",
|
name: "station",
|
||||||
create: "/station/create",
|
list: "/station",
|
||||||
edit: "/station/edit/:id",
|
create: "/station/create",
|
||||||
show: "/station/show/:id",
|
edit: "/station/edit/:id",
|
||||||
meta: {
|
show: "/station/show/:id",
|
||||||
canDelete: true,
|
meta: {
|
||||||
label: "Остановки",
|
canDelete: true,
|
||||||
icon: <StationIcon />,
|
label: "Остановки",
|
||||||
},
|
icon: <StationIcon />,
|
||||||
},
|
},
|
||||||
{
|
},
|
||||||
name: "vehicle",
|
{
|
||||||
list: "/vehicle",
|
name: "vehicle",
|
||||||
create: "/vehicle/create",
|
list: "/vehicle",
|
||||||
edit: "/vehicle/edit/:id",
|
create: "/vehicle/create",
|
||||||
show: "/vehicle/show/:id",
|
edit: "/vehicle/edit/:id",
|
||||||
meta: {
|
show: "/vehicle/show/:id",
|
||||||
canDelete: true,
|
meta: {
|
||||||
label: "Транспорт",
|
canDelete: true,
|
||||||
icon: <VehicleIcon />,
|
label: "Транспорт",
|
||||||
},
|
icon: <VehicleIcon />,
|
||||||
},
|
},
|
||||||
{
|
},
|
||||||
name: "route",
|
{
|
||||||
list: "/route",
|
name: "route",
|
||||||
create: "/route/create",
|
list: "/route",
|
||||||
edit: "/route/edit/:id",
|
create: "/route/create",
|
||||||
show: "/route/show/:id",
|
edit: "/route/edit/:id",
|
||||||
meta: {
|
show: "/route/show/:id",
|
||||||
canDelete: true,
|
meta: {
|
||||||
label: "Маршруты",
|
canDelete: true,
|
||||||
icon: <RouteIcon />,
|
label: "Маршруты",
|
||||||
},
|
icon: <RouteIcon />,
|
||||||
},
|
},
|
||||||
{
|
},
|
||||||
name: "user",
|
{
|
||||||
list: "/user",
|
name: "user",
|
||||||
create: "/user/create",
|
list: "/user",
|
||||||
edit: "/user/edit/:id",
|
create: "/user/create",
|
||||||
show: "/user/show/:id",
|
edit: "/user/edit/:id",
|
||||||
meta: {
|
show: "/user/show/:id",
|
||||||
canDelete: true,
|
meta: {
|
||||||
label: "Пользователи",
|
canDelete: true,
|
||||||
icon: <UsersIcon />,
|
label: "Пользователи",
|
||||||
},
|
icon: <UsersIcon />,
|
||||||
},
|
},
|
||||||
]}
|
},
|
||||||
options={{
|
]}
|
||||||
syncWithLocation: true,
|
options={{
|
||||||
warnWhenUnsavedChanges: true, // Включаем глобально
|
syncWithLocation: true,
|
||||||
useNewQueryKeys: true,
|
warnWhenUnsavedChanges: true, // Включаем глобально
|
||||||
projectId: "Wv044J-t53S3s-PcbJGe",
|
useNewQueryKeys: true,
|
||||||
}}
|
projectId: "Wv044J-t53S3s-PcbJGe",
|
||||||
>
|
}}
|
||||||
|
>
|
||||||
|
<KBarProvider>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route
|
<Route
|
||||||
element={
|
element={
|
||||||
@ -265,7 +260,6 @@ function App() {
|
|||||||
/>
|
/>
|
||||||
<Route path="show/:id" element={<CountryShow />} />
|
<Route path="show/:id" element={<CountryShow />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="dashboard" element={<Dashboard />} />
|
|
||||||
|
|
||||||
<Route path="/city">
|
<Route path="/city">
|
||||||
<Route index element={<CityList />} />
|
<Route index element={<CityList />} />
|
||||||
@ -363,7 +357,6 @@ function App() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route path="show/:id" element={<RouteShow />} />
|
<Route path="show/:id" element={<RouteShow />} />
|
||||||
<Route path="preview/:id" element={<RoutePreview />} />
|
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
<Route path="/user">
|
<Route path="/user">
|
||||||
@ -425,14 +418,15 @@ function App() {
|
|||||||
return "Белые ночи";
|
return "Белые ночи";
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Refine>
|
<RefineKbar />
|
||||||
<DevtoolsPanel />
|
</KBarProvider>
|
||||||
</DevtoolsProvider>
|
</Refine>
|
||||||
</RefineSnackbarProvider>
|
<DevtoolsPanel />
|
||||||
</ColorModeContextProvider>
|
</DevtoolsProvider>
|
||||||
</BrowserRouter>
|
</RefineSnackbarProvider>
|
||||||
</LoadingProvider>
|
</ColorModeContextProvider>
|
||||||
</QueryClientProvider>
|
</HashRouter>
|
||||||
|
</LoadingProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -6,6 +6,7 @@ import IconButton from "@mui/material/IconButton";
|
|||||||
import Stack from "@mui/material/Stack";
|
import Stack from "@mui/material/Stack";
|
||||||
import Toolbar from "@mui/material/Toolbar";
|
import Toolbar from "@mui/material/Toolbar";
|
||||||
import Typography from "@mui/material/Typography";
|
import Typography from "@mui/material/Typography";
|
||||||
|
import { languageStore } from "../../store/LanguageStore";
|
||||||
import {
|
import {
|
||||||
useGetIdentity,
|
useGetIdentity,
|
||||||
useList,
|
useList,
|
||||||
@ -21,7 +22,6 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
Select,
|
Select,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
InputLabel,
|
|
||||||
FormControl,
|
FormControl,
|
||||||
SelectChangeEvent,
|
SelectChangeEvent,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
@ -38,6 +38,7 @@ type IUser = {
|
|||||||
export const Header: React.FC<RefineThemedLayoutV2HeaderProps> = observer(
|
export const Header: React.FC<RefineThemedLayoutV2HeaderProps> = observer(
|
||||||
({ sticky }) => {
|
({ sticky }) => {
|
||||||
const { city_id, setCityIdAction } = cityStore;
|
const { city_id, setCityIdAction } = cityStore;
|
||||||
|
const { language } = languageStore;
|
||||||
const { data: cities } = useList({
|
const { data: cities } = useList({
|
||||||
resource: "city",
|
resource: "city",
|
||||||
});
|
});
|
||||||
@ -97,6 +98,7 @@ export const Header: React.FC<RefineThemedLayoutV2HeaderProps> = observer(
|
|||||||
|
|
||||||
// После сохранения меняем язык и возвращаемся на ту же страницу
|
// После сохранения меняем язык и возвращаемся на ту же страницу
|
||||||
Cookies.set("lang", lang);
|
Cookies.set("lang", lang);
|
||||||
|
|
||||||
i18n.changeLanguage(lang);
|
i18n.changeLanguage(lang);
|
||||||
navigate(currentLocation);
|
navigate(currentLocation);
|
||||||
return;
|
return;
|
||||||
@ -149,6 +151,9 @@ export const Header: React.FC<RefineThemedLayoutV2HeaderProps> = observer(
|
|||||||
value={city_id}
|
value={city_id}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
>
|
>
|
||||||
|
<MenuItem value={String(0)} key={0}>
|
||||||
|
Все города
|
||||||
|
</MenuItem>
|
||||||
{cities.data?.map((city) => (
|
{cities.data?.map((city) => (
|
||||||
<MenuItem value={String(city.id)} key={city.id}>
|
<MenuItem value={String(city.id)} key={city.id}>
|
||||||
{city.name}
|
{city.name}
|
||||||
@ -157,31 +162,6 @@ export const Header: React.FC<RefineThemedLayoutV2HeaderProps> = observer(
|
|||||||
</Select>
|
</Select>
|
||||||
)}
|
)}
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<Stack
|
|
||||||
direction="row"
|
|
||||||
spacing={1}
|
|
||||||
sx={{
|
|
||||||
backgroundColor: "background.paper",
|
|
||||||
padding: "4px",
|
|
||||||
borderRadius: "4px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{["ru", "en", "zh"].map((lang) => (
|
|
||||||
<Button
|
|
||||||
key={lang}
|
|
||||||
onClick={() => handleLanguageChange(lang)}
|
|
||||||
variant={i18n.language === lang ? "contained" : "outlined"}
|
|
||||||
size="small"
|
|
||||||
sx={{
|
|
||||||
minWidth: "30px",
|
|
||||||
padding: "2px 0px",
|
|
||||||
textTransform: "uppercase",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{lang}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<IconButton
|
<IconButton
|
||||||
color="inherit"
|
color="inherit"
|
||||||
|
@ -1,72 +1,142 @@
|
|||||||
import {useState} from 'react'
|
import { useState } from "react";
|
||||||
import {UseFormSetError, UseFormClearErrors, UseFormSetValue} from 'react-hook-form'
|
import {
|
||||||
|
UseFormSetError,
|
||||||
|
UseFormClearErrors,
|
||||||
|
UseFormSetValue,
|
||||||
|
} from "react-hook-form";
|
||||||
|
|
||||||
export const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
|
export const ALLOWED_IMAGE_TYPES = [
|
||||||
export const ALLOWED_VIDEO_TYPES = ['video/mp4', 'video/webm', 'video/ogg']
|
"image/jpeg",
|
||||||
|
"image/png",
|
||||||
|
"image/gif",
|
||||||
|
"image/webp",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ALLOWED_VIDEO_TYPES = ["video/mp4", "video/webm", "video/ogg"];
|
||||||
|
|
||||||
|
export const ALLOWED_PANORAMA_TYPES = [
|
||||||
|
"image/jpeg",
|
||||||
|
"image/jpg",
|
||||||
|
"image/png",
|
||||||
|
"image/gif",
|
||||||
|
"image/webp",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ALLOWED_ICON_TYPES = [
|
||||||
|
"image/svg+xml",
|
||||||
|
"image/png",
|
||||||
|
"image/jpg",
|
||||||
|
"image/jpeg",
|
||||||
|
"image/webp",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ALLOWED_WATERMARK_TYPES = [
|
||||||
|
"image/svg+xml",
|
||||||
|
"image/png",
|
||||||
|
"image/jpg",
|
||||||
|
"image/jpeg",
|
||||||
|
"image/webp",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ALLOWED_3D_MODEL_TYPES = [
|
||||||
|
".glb",
|
||||||
|
"glb",
|
||||||
|
".gltf",
|
||||||
|
"gltf",
|
||||||
|
"model/gltf-binary",
|
||||||
|
".vnd.ms-3d",
|
||||||
|
];
|
||||||
|
|
||||||
export const validateFileType = (file: File, mediaType: number) => {
|
export const validateFileType = (file: File, mediaType: number) => {
|
||||||
if (mediaType === 1 && !ALLOWED_IMAGE_TYPES.includes(file.type)) {
|
if (mediaType === 1 && !ALLOWED_IMAGE_TYPES.includes(file.type)) {
|
||||||
return 'Для типа "Фото" разрешены только форматы: JPG, PNG, GIF, WEBP'
|
return 'Для типа "Фото" разрешены только форматы: JPG, PNG, GIF, WEBP';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mediaType === 2 && !ALLOWED_VIDEO_TYPES.includes(file.type)) {
|
if (mediaType === 2 && !ALLOWED_VIDEO_TYPES.includes(file.type)) {
|
||||||
return 'Для типа "Видео" разрешены только форматы: MP4, WEBM, OGG'
|
return 'Для типа "Видео" разрешены только форматы: MP4, WEBM, OGG';
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
if (mediaType === 3 && !ALLOWED_ICON_TYPES.includes(file.type)) {
|
||||||
}
|
return 'Для типа "Иконка" разрешены только форматы: SVG, PNG, JPG, JPEG, WEBP';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mediaType === 4 && !ALLOWED_WATERMARK_TYPES.includes(file.type)) {
|
||||||
|
return 'Для типа "Водяной знак" разрешены только форматы: SVG, PNG, JPG, JPEG, WEBP';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mediaType === 5 && !ALLOWED_PANORAMA_TYPES.includes(file.type)) {
|
||||||
|
return 'Для типа "Панорама" разрешены только форматы: JPG, PNG, GIF, WEBP';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mediaType === 6 && !ALLOWED_3D_MODEL_TYPES.includes(file.type)) {
|
||||||
|
const extension = file.name.split(".").pop();
|
||||||
|
const isMimeTypeValid = ["model/gltf-binary"].includes(file.type);
|
||||||
|
const isExtensionValid =
|
||||||
|
extension && ALLOWED_3D_MODEL_TYPES.includes(extension);
|
||||||
|
if (!isMimeTypeValid && !isExtensionValid) {
|
||||||
|
return 'Для типа "3D-модель" разрешены только форматы: GLB, GLTF';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
type UseMediaFileUploadProps = {
|
type UseMediaFileUploadProps = {
|
||||||
selectedMediaType: number
|
selectedMediaType: number;
|
||||||
setError: UseFormSetError<any>
|
setError: UseFormSetError<any>;
|
||||||
clearErrors: UseFormClearErrors<any>
|
clearErrors: UseFormClearErrors<any>;
|
||||||
setValue: UseFormSetValue<any>
|
setValue: UseFormSetValue<any>;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const useMediaFileUpload = ({selectedMediaType, setError, clearErrors, setValue}: UseMediaFileUploadProps) => {
|
export const useMediaFileUpload = ({
|
||||||
const [selectedFile, setSelectedFile] = useState<File | null>(null)
|
selectedMediaType,
|
||||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
|
setError,
|
||||||
|
clearErrors,
|
||||||
|
setValue,
|
||||||
|
}: UseMediaFileUploadProps) => {
|
||||||
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||||
|
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||||
|
|
||||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = event.target.files?.[0]
|
const file = event.target.files?.[0];
|
||||||
if (!file) return
|
if (!file) return;
|
||||||
|
|
||||||
if (selectedMediaType) {
|
if (selectedMediaType) {
|
||||||
const error = validateFileType(file, selectedMediaType)
|
const error = validateFileType(file, selectedMediaType);
|
||||||
if (error) {
|
if (error) {
|
||||||
setError('file', {type: 'manual', message: error})
|
setError("file", { type: "manual", message: error });
|
||||||
event.target.value = ''
|
event.target.value = "";
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
clearErrors('file')
|
clearErrors("file");
|
||||||
setValue('file', file)
|
setValue("file", file);
|
||||||
setSelectedFile(file)
|
setSelectedFile(file);
|
||||||
|
|
||||||
if (file.type.startsWith('image/')) {
|
if (file.type.startsWith("image/")) {
|
||||||
const url = URL.createObjectURL(file)
|
const url = URL.createObjectURL(file);
|
||||||
setPreviewUrl(url)
|
setPreviewUrl(url);
|
||||||
} else {
|
} else {
|
||||||
setPreviewUrl(null)
|
setPreviewUrl(null);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleMediaTypeChange = (newMediaType: number | null) => {
|
const handleMediaTypeChange = (newMediaType: number | null) => {
|
||||||
setValue('media_type', newMediaType || null)
|
setValue("media_type", newMediaType || null);
|
||||||
|
|
||||||
if (selectedFile && newMediaType) {
|
if (selectedFile && newMediaType) {
|
||||||
const error = validateFileType(selectedFile, newMediaType)
|
const error = validateFileType(selectedFile, newMediaType);
|
||||||
if (error) {
|
if (error) {
|
||||||
setError('file', {type: 'manual', message: error})
|
setError("file", { type: "manual", message: error });
|
||||||
setValue('file', null)
|
setValue("file", null);
|
||||||
setSelectedFile(null)
|
setSelectedFile(null);
|
||||||
setPreviewUrl(null)
|
setPreviewUrl(null);
|
||||||
} else {
|
} else {
|
||||||
clearErrors('file')
|
clearErrors("file");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
selectedFile,
|
selectedFile,
|
||||||
@ -75,5 +145,5 @@ export const useMediaFileUpload = ({selectedMediaType, setError, clearErrors, se
|
|||||||
setPreviewUrl,
|
setPreviewUrl,
|
||||||
handleFileChange,
|
handleFileChange,
|
||||||
handleMediaTypeChange,
|
handleMediaTypeChange,
|
||||||
}
|
};
|
||||||
}
|
};
|
||||||
|
@ -1,6 +1,10 @@
|
|||||||
export const MEDIA_TYPES = [
|
export const MEDIA_TYPES = [
|
||||||
{ label: "Фото", value: 1 },
|
{ label: "Фото", value: 1 },
|
||||||
{ label: "Видео", value: 2 },
|
{ label: "Видео", value: 2 },
|
||||||
|
{ label: "Иконка", value: 3 },
|
||||||
|
{ label: "Водяной знак", value: 4 },
|
||||||
|
{ label: "Панорама", value: 5 },
|
||||||
|
{ label: "3Д-модель", value: 6 },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const VEHICLE_TYPES = [
|
export const VEHICLE_TYPES = [
|
||||||
|
@ -22,7 +22,7 @@ export const ArticleEdit = () => {
|
|||||||
// useEffect(() => {
|
// useEffect(() => {
|
||||||
// Cookies.set("lang", initialLanguage);
|
// Cookies.set("lang", initialLanguage);
|
||||||
// }, [pathname]);
|
// }, [pathname]);
|
||||||
const [language, setLanguage] = useState(Cookies.get("lang")!);
|
const [language, setLanguage] = useState(Cookies.get("lang") || "ru");
|
||||||
const [articleData, setArticleData] = useState<{
|
const [articleData, setArticleData] = useState<{
|
||||||
ru: { heading: string; body: string };
|
ru: { heading: string; body: string };
|
||||||
en: { heading: string; body: string };
|
en: { heading: string; body: string };
|
||||||
|
@ -20,7 +20,7 @@ export const CarrierList = observer(() => {
|
|||||||
{
|
{
|
||||||
field: "cityID",
|
field: "cityID",
|
||||||
operator: "eq",
|
operator: "eq",
|
||||||
value: city_id,
|
value: city_id === "0" ? null : city_id,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
import { Icons } from "../../preview/components";
|
|
||||||
import { Box, TextField } from "@mui/material";
|
import { Box, TextField } from "@mui/material";
|
||||||
import { Edit } from "@refinedev/mui";
|
import { Edit } from "@refinedev/mui";
|
||||||
import { useForm } from "@refinedev/react-hook-form";
|
import { useForm } from "@refinedev/react-hook-form";
|
||||||
|
21
src/pages/media/ModelViewer/index.tsx
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { Canvas } from "@react-three/fiber";
|
||||||
|
import { OrbitControls, Stage, useGLTF } from "@react-three/drei";
|
||||||
|
|
||||||
|
type ModelViewerProps = {
|
||||||
|
fileUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ModelViewer = ({ fileUrl }: ModelViewerProps) => {
|
||||||
|
const { scene } = useGLTF(fileUrl);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Canvas style={{ width: "100%", height: "80vh" }}>
|
||||||
|
<ambientLight />
|
||||||
|
<directionalLight />
|
||||||
|
<Stage environment="city" intensity={0.6}>
|
||||||
|
<primitive object={scene} />
|
||||||
|
</Stage>
|
||||||
|
<OrbitControls />
|
||||||
|
</Canvas>
|
||||||
|
);
|
||||||
|
};
|
@ -1,39 +1,58 @@
|
|||||||
import {Box, TextField, Button, Typography, Autocomplete} from '@mui/material'
|
import {
|
||||||
import {Create} from '@refinedev/mui'
|
Box,
|
||||||
import {useForm} from '@refinedev/react-hook-form'
|
TextField,
|
||||||
import {Controller} from 'react-hook-form'
|
Button,
|
||||||
|
Typography,
|
||||||
|
Autocomplete,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { Create } from "@refinedev/mui";
|
||||||
|
import { useForm } from "@refinedev/react-hook-form";
|
||||||
|
import { Controller } from "react-hook-form";
|
||||||
|
|
||||||
import {MEDIA_TYPES} from '../../lib/constants'
|
import { MEDIA_TYPES } from "../../lib/constants";
|
||||||
import {ALLOWED_IMAGE_TYPES, ALLOWED_VIDEO_TYPES, useMediaFileUpload} from '../../components/media/MediaFormUtils'
|
import {
|
||||||
|
ALLOWED_IMAGE_TYPES,
|
||||||
|
ALLOWED_ICON_TYPES,
|
||||||
|
ALLOWED_PANORAMA_TYPES,
|
||||||
|
ALLOWED_VIDEO_TYPES,
|
||||||
|
ALLOWED_WATERMARK_TYPES,
|
||||||
|
ALLOWED_3D_MODEL_TYPES,
|
||||||
|
useMediaFileUpload,
|
||||||
|
} from "../../components/media/MediaFormUtils";
|
||||||
|
import { ReactPhotoSphereViewer } from "react-photo-sphere-viewer";
|
||||||
|
import { ModelViewer } from "./ModelViewer/index";
|
||||||
|
|
||||||
type MediaFormValues = {
|
type MediaFormValues = {
|
||||||
media_name: string
|
media_name: string;
|
||||||
media_type: number
|
media_type: number;
|
||||||
file?: File
|
file?: File;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const MediaCreate = () => {
|
export const MediaCreate = () => {
|
||||||
const {
|
const {
|
||||||
saveButtonProps,
|
saveButtonProps,
|
||||||
refineCore: {formLoading, onFinish},
|
refineCore: { formLoading, onFinish },
|
||||||
register,
|
register,
|
||||||
control,
|
control,
|
||||||
formState: {errors},
|
formState: { errors },
|
||||||
setValue,
|
setValue,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
watch,
|
watch,
|
||||||
setError,
|
setError,
|
||||||
clearErrors,
|
clearErrors,
|
||||||
} = useForm<MediaFormValues>({})
|
getValues,
|
||||||
|
} = useForm<MediaFormValues>({});
|
||||||
|
|
||||||
const selectedMediaType = watch('media_type')
|
const selectedMediaType = watch("media_type");
|
||||||
|
const file = getValues("file");
|
||||||
|
|
||||||
const {selectedFile, previewUrl, handleFileChange, handleMediaTypeChange} = useMediaFileUpload({
|
const { selectedFile, previewUrl, handleFileChange, handleMediaTypeChange } =
|
||||||
selectedMediaType,
|
useMediaFileUpload({
|
||||||
setError,
|
selectedMediaType,
|
||||||
clearErrors,
|
setError,
|
||||||
setValue,
|
clearErrors,
|
||||||
})
|
setValue,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Create
|
<Create
|
||||||
@ -42,19 +61,20 @@ export const MediaCreate = () => {
|
|||||||
...saveButtonProps,
|
...saveButtonProps,
|
||||||
disabled: !!errors.file || !selectedFile,
|
disabled: !!errors.file || !selectedFile,
|
||||||
onClick: handleSubmit((data) => {
|
onClick: handleSubmit((data) => {
|
||||||
|
console.log(data);
|
||||||
if (data.file) {
|
if (data.file) {
|
||||||
const formData = new FormData()
|
const formData = new FormData();
|
||||||
formData.append('media_name', data.media_name)
|
formData.append("media_name", data.media_name);
|
||||||
formData.append('filename', data.file.name)
|
formData.append("filename", data.file.name);
|
||||||
formData.append('type', String(data.media_type))
|
formData.append("type", String(data.media_type));
|
||||||
formData.append('file', data.file)
|
formData.append("file", data.file);
|
||||||
|
|
||||||
console.log('Отправляемые данные:')
|
console.log("Отправляемые данные:");
|
||||||
for (const pair of formData.entries()) {
|
for (const pair of formData.entries()) {
|
||||||
console.log(pair[0] + ': ' + pair[1])
|
console.log(pair[0] + ": " + pair[1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
onFinish(formData)
|
onFinish(formData);
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
}}
|
}}
|
||||||
@ -63,47 +83,97 @@ export const MediaCreate = () => {
|
|||||||
control={control}
|
control={control}
|
||||||
name="media_type"
|
name="media_type"
|
||||||
rules={{
|
rules={{
|
||||||
required: 'Это поле является обязательным',
|
required: "Это поле является обязательным",
|
||||||
}}
|
}}
|
||||||
render={({field}) => (
|
render={({ field }) => (
|
||||||
<Autocomplete
|
<Autocomplete
|
||||||
options={MEDIA_TYPES}
|
options={MEDIA_TYPES}
|
||||||
value={MEDIA_TYPES.find((option) => option.value === field.value) || null}
|
value={
|
||||||
|
MEDIA_TYPES.find((option) => option.value === field.value) || null
|
||||||
|
}
|
||||||
onChange={(_, value) => {
|
onChange={(_, value) => {
|
||||||
field.onChange(value?.value || null)
|
field.onChange(value?.value || null);
|
||||||
handleMediaTypeChange(value?.value || null)
|
handleMediaTypeChange(value?.value || null);
|
||||||
}}
|
}}
|
||||||
getOptionLabel={(item) => {
|
getOptionLabel={(item) => {
|
||||||
return item ? item.label : ''
|
return item ? item.label : "";
|
||||||
}}
|
}}
|
||||||
isOptionEqualToValue={(option, value) => {
|
isOptionEqualToValue={(option, value) => {
|
||||||
return option.value === value?.value
|
return option.value === value?.value;
|
||||||
}}
|
}}
|
||||||
renderInput={(params) => <TextField {...params} label="Тип" margin="normal" variant="outlined" error={!!errors.media_type} helperText={(errors as any)?.media_type?.message} required />}
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
label="Тип"
|
||||||
|
margin="normal"
|
||||||
|
variant="outlined"
|
||||||
|
error={!!errors.media_type}
|
||||||
|
helperText={(errors as any)?.media_type?.message}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
{...register('media_name', {
|
{...register("media_name", {
|
||||||
required: 'Это поле является обязательным',
|
required: "Это поле является обязательным",
|
||||||
})}
|
})}
|
||||||
error={!!(errors as any)?.media_name}
|
error={!!(errors as any)?.media_name}
|
||||||
helperText={(errors as any)?.media_name?.message}
|
helperText={(errors as any)?.media_name?.message}
|
||||||
margin="normal"
|
margin="normal"
|
||||||
fullWidth
|
fullWidth
|
||||||
InputLabelProps={{shrink: true}}
|
InputLabelProps={{ shrink: true }}
|
||||||
type="text"
|
type="text"
|
||||||
label="Название *"
|
label="Название *"
|
||||||
name="media_name"
|
name="media_name"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Box component="form" sx={{display: 'flex', flexDirection: 'column'}} autoComplete="off" style={{marginTop: 10}}>
|
<Box
|
||||||
<Box display="flex" flexDirection="column-reverse" alignItems="center" gap={6}>
|
component="form"
|
||||||
<Box display="flex" flexDirection="column" alignItems="center" gap={2}>
|
sx={{ display: "flex", flexDirection: "column" }}
|
||||||
<Button variant="contained" component="label" disabled={!selectedMediaType}>
|
autoComplete="off"
|
||||||
{selectedFile ? 'Изменить файл' : 'Загрузить файл'}
|
style={{ marginTop: 10 }}
|
||||||
<input type="file" hidden onChange={handleFileChange} accept={selectedMediaType === 1 ? ALLOWED_IMAGE_TYPES.join(',') : ALLOWED_VIDEO_TYPES.join(',')} />
|
>
|
||||||
|
<Box
|
||||||
|
display="flex"
|
||||||
|
flexDirection="column-reverse"
|
||||||
|
alignItems="center"
|
||||||
|
gap={6}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
display="flex"
|
||||||
|
flexDirection="column"
|
||||||
|
alignItems="center"
|
||||||
|
gap={2}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
component="label"
|
||||||
|
disabled={!selectedMediaType}
|
||||||
|
>
|
||||||
|
{selectedFile ? "Изменить файл" : "Загрузить файл"}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
hidden
|
||||||
|
onChange={handleFileChange}
|
||||||
|
accept={
|
||||||
|
selectedMediaType === 6
|
||||||
|
? ALLOWED_3D_MODEL_TYPES.join(",")
|
||||||
|
: selectedMediaType === 1
|
||||||
|
? ALLOWED_IMAGE_TYPES.join(",")
|
||||||
|
: selectedMediaType === 2
|
||||||
|
? ALLOWED_VIDEO_TYPES.join(",")
|
||||||
|
: selectedMediaType === 3
|
||||||
|
? ALLOWED_ICON_TYPES.join(",")
|
||||||
|
: selectedMediaType === 4
|
||||||
|
? ALLOWED_WATERMARK_TYPES.join(",")
|
||||||
|
: selectedMediaType === 5
|
||||||
|
? ALLOWED_PANORAMA_TYPES.join(",")
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{selectedFile && (
|
{selectedFile && (
|
||||||
@ -121,11 +191,53 @@ export const MediaCreate = () => {
|
|||||||
|
|
||||||
{previewUrl && selectedMediaType === 1 && (
|
{previewUrl && selectedMediaType === 1 && (
|
||||||
<Box mt={2} display="flex" justifyContent="center">
|
<Box mt={2} display="flex" justifyContent="center">
|
||||||
<img src={previewUrl} alt="Preview" style={{maxWidth: '200px', borderRadius: 8}} />
|
<img
|
||||||
|
src={previewUrl}
|
||||||
|
alt="Preview"
|
||||||
|
style={{ maxWidth: "200px", borderRadius: 8 }}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{file && selectedMediaType === 2 && (
|
||||||
|
<Box mt={2} display="flex" justifyContent="center">
|
||||||
|
<video src={URL.createObjectURL(file)} autoPlay controls />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{previewUrl && selectedMediaType === 3 && (
|
||||||
|
<Box mt={2} display="flex" justifyContent="center">
|
||||||
|
<img
|
||||||
|
src={previewUrl}
|
||||||
|
alt="Preview"
|
||||||
|
style={{ maxWidth: "200px", borderRadius: 8 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{previewUrl && selectedMediaType === 4 && (
|
||||||
|
<Box mt={2} display="flex" justifyContent="center">
|
||||||
|
<img
|
||||||
|
src={previewUrl}
|
||||||
|
alt="Preview"
|
||||||
|
style={{ maxWidth: "200px", borderRadius: 8 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{file && selectedMediaType === 5 && (
|
||||||
|
<ReactPhotoSphereViewer
|
||||||
|
src={URL.createObjectURL(file)}
|
||||||
|
width={"100%"}
|
||||||
|
height={"80vh"}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{file && previewUrl && selectedMediaType === 6 && (
|
||||||
|
<ModelViewer fileUrl={URL.createObjectURL(file)} />
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Create>
|
</Create>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
@ -11,13 +11,17 @@ import { useEffect } from "react";
|
|||||||
import { useShow } from "@refinedev/core";
|
import { useShow } from "@refinedev/core";
|
||||||
import { Controller } from "react-hook-form";
|
import { Controller } from "react-hook-form";
|
||||||
|
|
||||||
|
import { TOKEN_KEY } from "../../authProvider";
|
||||||
import { MEDIA_TYPES } from "../../lib/constants";
|
import { MEDIA_TYPES } from "../../lib/constants";
|
||||||
import {
|
import {
|
||||||
ALLOWED_IMAGE_TYPES,
|
ALLOWED_IMAGE_TYPES,
|
||||||
ALLOWED_VIDEO_TYPES,
|
ALLOWED_VIDEO_TYPES,
|
||||||
|
ALLOWED_ICON_TYPES,
|
||||||
|
ALLOWED_WATERMARK_TYPES,
|
||||||
|
ALLOWED_PANORAMA_TYPES,
|
||||||
|
ALLOWED_3D_MODEL_TYPES,
|
||||||
useMediaFileUpload,
|
useMediaFileUpload,
|
||||||
} from "../../components/media/MediaFormUtils";
|
} from "../../components/media/MediaFormUtils";
|
||||||
import { TOKEN_KEY } from "../../authProvider";
|
|
||||||
|
|
||||||
type MediaFormValues = {
|
type MediaFormValues = {
|
||||||
media_name: string;
|
media_name: string;
|
||||||
@ -175,7 +179,17 @@ export const MediaEdit = () => {
|
|||||||
accept={
|
accept={
|
||||||
selectedMediaType === 1
|
selectedMediaType === 1
|
||||||
? ALLOWED_IMAGE_TYPES.join(",")
|
? ALLOWED_IMAGE_TYPES.join(",")
|
||||||
: ALLOWED_VIDEO_TYPES.join(",")
|
: selectedMediaType === 2
|
||||||
|
? ALLOWED_VIDEO_TYPES.join(",")
|
||||||
|
: selectedMediaType === 3
|
||||||
|
? ALLOWED_ICON_TYPES.join(",")
|
||||||
|
: selectedMediaType === 4
|
||||||
|
? ALLOWED_WATERMARK_TYPES.join(",")
|
||||||
|
: selectedMediaType === 5
|
||||||
|
? ALLOWED_PANORAMA_TYPES.join(",")
|
||||||
|
: selectedMediaType === 6
|
||||||
|
? ALLOWED_3D_MODEL_TYPES.join(",")
|
||||||
|
: ""
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
|
@ -1,9 +1,11 @@
|
|||||||
import { Stack, Typography, Box, Button } from "@mui/material";
|
import { Stack, Typography, Box, Button } from "@mui/material";
|
||||||
import { useShow } from "@refinedev/core";
|
import { useShow } from "@refinedev/core";
|
||||||
import { Show, TextFieldComponent as TextField } from "@refinedev/mui";
|
import { Show, TextFieldComponent as TextField } from "@refinedev/mui";
|
||||||
|
import { ReactPhotoSphereViewer } from "react-photo-sphere-viewer";
|
||||||
|
import sky from "./12414.jpg";
|
||||||
import { MEDIA_TYPES } from "../../lib/constants";
|
import { MEDIA_TYPES } from "../../lib/constants";
|
||||||
import { TOKEN_KEY } from "../../authProvider";
|
import { TOKEN_KEY } from "../../authProvider";
|
||||||
|
import { ModelViewer } from "./ModelViewer/index";
|
||||||
|
|
||||||
export const MediaShow = () => {
|
export const MediaShow = () => {
|
||||||
const { query } = useShow({});
|
const { query } = useShow({});
|
||||||
@ -43,53 +45,67 @@ export const MediaShow = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{record && record.media_type === 2 && (
|
{record && record.media_type === 2 && (
|
||||||
<>
|
<video
|
||||||
<video
|
src={`${import.meta.env.VITE_KRBL_MEDIA}${
|
||||||
src={`${import.meta.env.VITE_KRBL_MEDIA}${
|
record?.id
|
||||||
record?.id
|
}/download?token=${token}`}
|
||||||
}/download?token=${token}`}
|
style={{
|
||||||
style={{
|
maxWidth: "50%",
|
||||||
maxWidth: "50%",
|
|
||||||
|
|
||||||
objectFit: "contain",
|
objectFit: "contain",
|
||||||
borderRadius: 30,
|
borderRadius: 30,
|
||||||
}}
|
}}
|
||||||
controls
|
controls
|
||||||
autoPlay
|
autoPlay
|
||||||
muted
|
muted
|
||||||
/>
|
/>
|
||||||
<Box
|
)}
|
||||||
sx={{
|
{record && record.media_type === 3 && (
|
||||||
p: 2,
|
<img
|
||||||
border: "1px solid text.pimary",
|
src={`${import.meta.env.VITE_KRBL_MEDIA}${
|
||||||
borderRadius: 2,
|
record?.id
|
||||||
bgcolor: "primary.light",
|
}/download?token=${token}`}
|
||||||
width: "fit-content",
|
alt={record?.filename}
|
||||||
}}
|
style={{
|
||||||
>
|
maxWidth: "100%",
|
||||||
<Typography
|
height: "40vh",
|
||||||
variant="body1"
|
objectFit: "contain",
|
||||||
gutterBottom
|
borderRadius: 8,
|
||||||
sx={{
|
}}
|
||||||
color: "#FFFFFF",
|
/>
|
||||||
}}
|
)}
|
||||||
>
|
{record && record.media_type === 4 && (
|
||||||
Видео доступно для скачивания по ссылке:
|
<img
|
||||||
</Typography>
|
src={`${import.meta.env.VITE_KRBL_MEDIA}${
|
||||||
<Button
|
record?.id
|
||||||
variant="contained"
|
}/download?token=${token}`}
|
||||||
href={`${import.meta.env.VITE_KRBL_MEDIA}${
|
alt={record?.filename}
|
||||||
record?.id
|
style={{
|
||||||
}/download?token=${token}`}
|
maxWidth: "100%",
|
||||||
target="_blank"
|
height: "40vh",
|
||||||
sx={{ mt: 1, width: "100%" }}
|
objectFit: "contain",
|
||||||
>
|
borderRadius: 8,
|
||||||
Скачать видео
|
}}
|
||||||
</Button>
|
/>
|
||||||
</Box>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{record && record.media_type === 5 && (
|
||||||
|
<ReactPhotoSphereViewer
|
||||||
|
src={`${import.meta.env.VITE_KRBL_MEDIA}${
|
||||||
|
record?.id
|
||||||
|
}/download?token=${token}`}
|
||||||
|
width={"100%px"}
|
||||||
|
height={"80vh"}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{record && record.media_type === 6 && (
|
||||||
|
<ModelViewer
|
||||||
|
fileUrl={`${import.meta.env.VITE_KRBL_MEDIA}${
|
||||||
|
record?.id
|
||||||
|
}/download?token=${token}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{fields.map(({ label, data, render }) => (
|
{fields.map(({ label, data, render }) => (
|
||||||
<Stack key={data} gap={1}>
|
<Stack key={data} gap={1}>
|
||||||
<Typography variant="body1" fontWeight="bold">
|
<Typography variant="body1" fontWeight="bold">
|
||||||
@ -100,6 +116,35 @@ export const MediaShow = () => {
|
|||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
))}
|
))}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
border: "1px solid text.pimary",
|
||||||
|
borderRadius: 2,
|
||||||
|
bgcolor: "primary.light",
|
||||||
|
width: "fit-content",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
variant="body1"
|
||||||
|
gutterBottom
|
||||||
|
sx={{
|
||||||
|
color: "#FFFFFF",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Доступно для скачивания по ссылке:
|
||||||
|
</Typography>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
href={`${import.meta.env.VITE_KRBL_MEDIA}${
|
||||||
|
record?.id
|
||||||
|
}/download?token=${token}`}
|
||||||
|
target="_blank"
|
||||||
|
sx={{ mt: 1, width: "100%" }}
|
||||||
|
>
|
||||||
|
Скачать медиа
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Show>
|
</Show>
|
||||||
);
|
);
|
||||||
|
@ -10,14 +10,15 @@ import {
|
|||||||
import { Edit, useAutocomplete } from "@refinedev/mui";
|
import { Edit, useAutocomplete } from "@refinedev/mui";
|
||||||
import { useForm } from "@refinedev/react-hook-form";
|
import { useForm } from "@refinedev/react-hook-form";
|
||||||
import { Controller } from "react-hook-form";
|
import { Controller } from "react-hook-form";
|
||||||
import { useParams } from "react-router";
|
import { useParams, Link } from "react-router";
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { LinkedItems } from "../../components/LinkedItems";
|
import { LinkedItems } from "../../components/LinkedItems";
|
||||||
import { CreateSightArticle } from "../../components/CreateSightArticle";
|
import { CreateSightArticle } from "../../components/CreateSightArticle";
|
||||||
import { ArticleItem, articleFields } from "./types";
|
import { ArticleItem, articleFields } from "./types";
|
||||||
import { TOKEN_KEY } from "../../authProvider";
|
import { TOKEN_KEY } from "../../authProvider";
|
||||||
import { Link } from "react-router";
|
import { observer } from "mobx-react-lite";
|
||||||
import Cookies from "js-cookie";
|
|
||||||
|
import { languageStore } from "../../store/LanguageStore";
|
||||||
|
|
||||||
function a11yProps(index: number) {
|
function a11yProps(index: number) {
|
||||||
return {
|
return {
|
||||||
@ -48,21 +49,9 @@ function CustomTabPanel(props: TabPanelProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SightEdit = () => {
|
export const SightEdit = observer(() => {
|
||||||
const { id: sightId } = useParams<{ id: string }>();
|
const { id: sightId } = useParams<{ id: string }>();
|
||||||
const [language, setLanguage] = useState(Cookies.get("lang") || "ru");
|
const { language, setLanguageAction } = languageStore;
|
||||||
|
|
||||||
const handleLanguageChange = (lang: string) => {
|
|
||||||
setLanguage(lang);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const lang = Cookies.get("lang")!;
|
|
||||||
Cookies.set("lang", language);
|
|
||||||
return () => {
|
|
||||||
Cookies.set("lang", lang);
|
|
||||||
};
|
|
||||||
}, [language]);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
saveButtonProps,
|
saveButtonProps,
|
||||||
@ -91,9 +80,6 @@ export const SightEdit = () => {
|
|||||||
value,
|
value,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
queryOptions: {
|
|
||||||
queryKey: ["sight", language],
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
const [tabValue, setTabValue] = useState(0);
|
const [tabValue, setTabValue] = useState(0);
|
||||||
const { autocompleteProps: mediaAutocompleteProps } = useAutocomplete({
|
const { autocompleteProps: mediaAutocompleteProps } = useAutocomplete({
|
||||||
@ -252,10 +238,12 @@ export const SightEdit = () => {
|
|||||||
onChange={(_, newValue) => setTabValue(newValue)}
|
onChange={(_, newValue) => setTabValue(newValue)}
|
||||||
aria-label="basic tabs example"
|
aria-label="basic tabs example"
|
||||||
>
|
>
|
||||||
<Tab label="Основная информация" {...a11yProps(0)} />
|
<Tab label="Основная информация" {...a11yProps(1)} />
|
||||||
<Tab label="Статьи" {...a11yProps(1)} />
|
<Tab label="Левый виджет" {...a11yProps(2)} />
|
||||||
|
<Tab label="Правый информация" {...a11yProps(3)} />
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<CustomTabPanel value={tabValue} index={0}>
|
<CustomTabPanel value={tabValue} index={0}>
|
||||||
<Edit saveButtonProps={saveButtonProps}>
|
<Edit saveButtonProps={saveButtonProps}>
|
||||||
<Box sx={{ display: "flex", gap: 2 }}>
|
<Box sx={{ display: "flex", gap: 2 }}>
|
||||||
@ -287,7 +275,7 @@ export const SightEdit = () => {
|
|||||||
p: 1,
|
p: 1,
|
||||||
borderRadius: 1,
|
borderRadius: 1,
|
||||||
}}
|
}}
|
||||||
onClick={() => handleLanguageChange("ru")}
|
onClick={() => setLanguageAction("ru")}
|
||||||
>
|
>
|
||||||
RU
|
RU
|
||||||
</Box>
|
</Box>
|
||||||
@ -302,7 +290,7 @@ export const SightEdit = () => {
|
|||||||
p: 1,
|
p: 1,
|
||||||
borderRadius: 1,
|
borderRadius: 1,
|
||||||
}}
|
}}
|
||||||
onClick={() => handleLanguageChange("en")}
|
onClick={() => setLanguageAction("en")}
|
||||||
>
|
>
|
||||||
EN
|
EN
|
||||||
</Box>
|
</Box>
|
||||||
@ -317,7 +305,7 @@ export const SightEdit = () => {
|
|||||||
p: 1,
|
p: 1,
|
||||||
borderRadius: 1,
|
borderRadius: 1,
|
||||||
}}
|
}}
|
||||||
onClick={() => handleLanguageChange("zh")}
|
onClick={() => setLanguageAction("zh")}
|
||||||
>
|
>
|
||||||
ZH
|
ZH
|
||||||
</Box>
|
</Box>
|
||||||
@ -896,14 +884,10 @@ export const SightEdit = () => {
|
|||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
</Box>
|
</Box>
|
||||||
</Edit>
|
|
||||||
</CustomTabPanel>
|
|
||||||
<CustomTabPanel value={tabValue} index={1}>
|
|
||||||
{sightId && (
|
|
||||||
<Box sx={{ mt: 3 }}>
|
<Box sx={{ mt: 3 }}>
|
||||||
<LinkedItems<ArticleItem>
|
<LinkedItems<ArticleItem>
|
||||||
type="edit"
|
type="edit"
|
||||||
parentId={sightId}
|
parentId={sightId!}
|
||||||
parentResource="sight"
|
parentResource="sight"
|
||||||
childResource="article"
|
childResource="article"
|
||||||
fields={articleFields}
|
fields={articleFields}
|
||||||
@ -911,14 +895,20 @@ export const SightEdit = () => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<CreateSightArticle
|
<CreateSightArticle
|
||||||
parentId={sightId}
|
parentId={sightId!}
|
||||||
parentResource="sight"
|
parentResource="sight"
|
||||||
childResource="article"
|
childResource="article"
|
||||||
title="статью"
|
title="статью"
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
</Edit>
|
||||||
|
</CustomTabPanel>
|
||||||
|
<CustomTabPanel value={tabValue} index={1}>
|
||||||
|
1
|
||||||
|
</CustomTabPanel>
|
||||||
|
<CustomTabPanel value={tabValue} index={2}>
|
||||||
|
2
|
||||||
</CustomTabPanel>
|
</CustomTabPanel>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
@ -22,7 +22,7 @@ export const SightList = observer(() => {
|
|||||||
{
|
{
|
||||||
field: "cityID",
|
field: "cityID",
|
||||||
operator: "eq",
|
operator: "eq",
|
||||||
value: city_id,
|
value: city_id === "0" ? null : city_id,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
@ -23,7 +23,7 @@ export const StationList = observer(() => {
|
|||||||
{
|
{
|
||||||
field: "cityID",
|
field: "cityID",
|
||||||
operator: "eq",
|
operator: "eq",
|
||||||
value: city_id,
|
value: city_id === "0" ? null : city_id,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
@ -1,39 +0,0 @@
|
|||||||
.attraction-card {
|
|
||||||
height: 415px;
|
|
||||||
width: 315px;
|
|
||||||
background: linear-gradient(
|
|
||||||
113.51deg,
|
|
||||||
rgba(255, 255, 255, 0) 8.71%,
|
|
||||||
rgba(255, 255, 255, 0.16) 69.69%
|
|
||||||
),
|
|
||||||
#806c59;
|
|
||||||
border-radius: 10px;
|
|
||||||
|
|
||||||
overflow: hidden;
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.attraction-card__content {
|
|
||||||
padding: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.attraction-card__title {
|
|
||||||
margin: 8px 0 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.attraction-card__text {
|
|
||||||
margin: 30px 0 0 0;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.attraction-card__subtitle {
|
|
||||||
margin: 8px 0 0 0;
|
|
||||||
font-weight: 400;
|
|
||||||
}
|
|
||||||
|
|
||||||
.attraction-card__image {
|
|
||||||
min-width: 100%;
|
|
||||||
max-height: 50%;
|
|
||||||
padding: 2px;
|
|
||||||
border-radius: 10px 10px 0 0;
|
|
||||||
}
|
|
@ -1,55 +0,0 @@
|
|||||||
import "./AttractionShortPreview.css";
|
|
||||||
|
|
||||||
import { LocalizedString, useServerLocalization } from "@mt/i18n";
|
|
||||||
import classNames from "classnames";
|
|
||||||
import { TouchScrollWrapper } from "../TouchScrollWrapper/TouchScrollWrapper";
|
|
||||||
import { HTMLAttributes } from "react";
|
|
||||||
|
|
||||||
export interface AttractionShortPreviewProps
|
|
||||||
extends Omit<HTMLAttributes<HTMLElement>, "title" | "content"> {
|
|
||||||
img: string;
|
|
||||||
title: LocalizedString;
|
|
||||||
subtitle: LocalizedString;
|
|
||||||
content: LocalizedString;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AttractionShortPreview({
|
|
||||||
img,
|
|
||||||
title,
|
|
||||||
subtitle,
|
|
||||||
content,
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: AttractionShortPreviewProps) {
|
|
||||||
const localizeText = useServerLocalization();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={classNames(className, "attraction-card g-flex-column")}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{img && (
|
|
||||||
<img
|
|
||||||
className="attraction-card__image"
|
|
||||||
src={img}
|
|
||||||
alt={localizeText(title)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<TouchScrollWrapper className="g-flex-column__item">
|
|
||||||
<div className="attraction-card__content">
|
|
||||||
<h4 className="attraction-card__title">{localizeText(title)}</h4>
|
|
||||||
|
|
||||||
<h5 className="attraction-card__subtitle">
|
|
||||||
{localizeText(subtitle)}
|
|
||||||
</h5>
|
|
||||||
|
|
||||||
<p
|
|
||||||
className="attraction-card__text"
|
|
||||||
dangerouslySetInnerHTML={{ __html: localizeText(content) }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</TouchScrollWrapper>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,126 +0,0 @@
|
|||||||
.widget-container {
|
|
||||||
width: 545px;
|
|
||||||
height: var(--attraction-widget-container-height, 100%);
|
|
||||||
max-height: calc(100% - 90px);
|
|
||||||
color: #ffffff;
|
|
||||||
background: #806c59;
|
|
||||||
border: 2px solid #806c59;
|
|
||||||
border-radius: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.widget-content {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: flex-start;
|
|
||||||
align-items: center;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.widget-slide {
|
|
||||||
position: relative;
|
|
||||||
display: none;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: flex-start;
|
|
||||||
align-items: center;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.widget-slide.active,
|
|
||||||
.widget-slide.preview {
|
|
||||||
display: flex;
|
|
||||||
flex: 1;
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.widget-media {
|
|
||||||
width: 100%;
|
|
||||||
height: auto;
|
|
||||||
max-height: 644px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.view-container {
|
|
||||||
border-radius: 8px 8px 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.widget-header {
|
|
||||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0) 100%),
|
|
||||||
rgba(179, 165, 152, 0.4);
|
|
||||||
box-shadow: inset 4px 4px 12px rgba(255, 255, 255, 0.12);
|
|
||||||
width: 100%;
|
|
||||||
padding: 9px 16px;
|
|
||||||
font-weight: 700;
|
|
||||||
font-size: 24px;
|
|
||||||
line-height: 120%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.widget-text {
|
|
||||||
width: 100%;
|
|
||||||
align-self: self-start;
|
|
||||||
padding: 16px;
|
|
||||||
font-weight: 400;
|
|
||||||
font-size: 18px;
|
|
||||||
line-height: 150%; /* or 27px */
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.5s ease-in-out;
|
|
||||||
user-select: none;
|
|
||||||
word-break: break-word;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.widget-text p {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.widget-text.preview {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
height: 100%;
|
|
||||||
font-weight: 700;
|
|
||||||
font-size: 48px;
|
|
||||||
line-height: 120%;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.widget-text.active {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.widget-titles {
|
|
||||||
display: flex;
|
|
||||||
height: 50px;
|
|
||||||
justify-content: space-evenly;
|
|
||||||
align-items: center;
|
|
||||||
width: 100%;
|
|
||||||
margin: 5px 0 0 0;
|
|
||||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0) 100%),
|
|
||||||
rgba(179, 165, 152, 0.4);
|
|
||||||
box-shadow: inset 4px 4px 12px rgba(255, 255, 255, 0.12);
|
|
||||||
border-radius: 0 0 10px 10px;
|
|
||||||
padding: 12px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.widget-title {
|
|
||||||
font-weight: 400;
|
|
||||||
font-size: 18px;
|
|
||||||
line-height: 21px;
|
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
width: 100px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.widget-title.active {
|
|
||||||
font-weight: bold;
|
|
||||||
text-decoration: underline;
|
|
||||||
text-underline-offset: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.widget-title.preview {
|
|
||||||
display: none;
|
|
||||||
}
|
|
@ -1,114 +0,0 @@
|
|||||||
import React, { HTMLAttributes, useEffect, useState } from "react";
|
|
||||||
import { useServerLocalization } from "@mt/i18n";
|
|
||||||
import cn from "classnames";
|
|
||||||
import { useSwipeable } from "react-swipeable";
|
|
||||||
import { ArticleBase } from "@mt/common-types";
|
|
||||||
import "./AttractionWidget.css";
|
|
||||||
import { usePrevious } from "@mt/utils";
|
|
||||||
import { AttractionMedia } from "./media/AttractionMedia";
|
|
||||||
import { TouchScrollWrapper } from "../TouchScrollWrapper/TouchScrollWrapper";
|
|
||||||
|
|
||||||
export interface AttractionsWidgetProps extends HTMLAttributes<HTMLElement> {
|
|
||||||
articles: ArticleBase[];
|
|
||||||
isIdleMode: boolean;
|
|
||||||
isPreviewOnly?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AttractionWidget({
|
|
||||||
articles,
|
|
||||||
isIdleMode,
|
|
||||||
isPreviewOnly = false,
|
|
||||||
...props
|
|
||||||
}: AttractionsWidgetProps) {
|
|
||||||
const [activeIndex, setActiveIndex] = useState(0);
|
|
||||||
const prevArticles = usePrevious<ArticleBase[]>(articles) || [];
|
|
||||||
const localizeText = useServerLocalization();
|
|
||||||
|
|
||||||
const swipeHandlers = useSwipeable({
|
|
||||||
onSwipedLeft: ({ event }) => {
|
|
||||||
event.preventDefault();
|
|
||||||
setActiveIndex((activeIndex) => (activeIndex + 1) % articles.length);
|
|
||||||
},
|
|
||||||
onSwipedRight: ({ event }) => {
|
|
||||||
event.preventDefault();
|
|
||||||
setActiveIndex(
|
|
||||||
(activeIndex) => (activeIndex - 1 + articles.length) % articles.length
|
|
||||||
);
|
|
||||||
},
|
|
||||||
swipeDuration: 500,
|
|
||||||
preventScrollOnSwipe: true,
|
|
||||||
trackMouse: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleClick = (index: number) => {
|
|
||||||
setActiveIndex(index);
|
|
||||||
document.querySelector(".widget-text.active")!.scrollTop = 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => setActiveIndex(activeIndex), [activeIndex]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
!isPreviewOnly &&
|
|
||||||
(isIdleMode || JSON.stringify(prevArticles) !== JSON.stringify(articles))
|
|
||||||
) {
|
|
||||||
setActiveIndex(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// admin specific case: during edit we removed active article
|
|
||||||
if (prevArticles?.length > articles?.length) {
|
|
||||||
setActiveIndex(0);
|
|
||||||
}
|
|
||||||
}, [isPreviewOnly, isIdleMode, articles]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="widget-container g-flex-column__item-fixed" {...props}>
|
|
||||||
<div className="widget-content">
|
|
||||||
{articles?.map((article, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className={`widget-slide ${index === activeIndex ? "active" : ""}`}
|
|
||||||
onPointerUp={() => handleClick(index)}
|
|
||||||
>
|
|
||||||
<div className="widget-media">
|
|
||||||
<AttractionMedia media={article.media} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{index !== 0 && (
|
|
||||||
<div className="widget-header">
|
|
||||||
{localizeText(articles[0].text)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<TouchScrollWrapper
|
|
||||||
className={cn("widget-text", {
|
|
||||||
active: index === activeIndex,
|
|
||||||
preview: article.isPreview,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
dangerouslySetInnerHTML={{ __html: localizeText(article.text) }}
|
|
||||||
{...swipeHandlers}
|
|
||||||
/>
|
|
||||||
</TouchScrollWrapper>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<div className="widget-titles">
|
|
||||||
{articles?.map((article, index) => (
|
|
||||||
<div
|
|
||||||
key={`title-${index}`}
|
|
||||||
className={cn("widget-title", {
|
|
||||||
active: index === activeIndex,
|
|
||||||
preview: article.isPreview,
|
|
||||||
})}
|
|
||||||
onPointerUp={() => handleClick(index)}
|
|
||||||
>
|
|
||||||
{localizeText(article.name)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,52 +0,0 @@
|
|||||||
.widget-image,
|
|
||||||
.widget-video,
|
|
||||||
.widget-3d-model {
|
|
||||||
max-width: 100%;
|
|
||||||
max-height: 100%;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
display: block;
|
|
||||||
border-radius: 8px 8px 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.widget-3d-model {
|
|
||||||
height: 350px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.widget-media__wrapper {
|
|
||||||
position: relative;
|
|
||||||
/*TODO: it worth to investigate it further... quite weird behavior of */
|
|
||||||
box-sizing: content-box !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullscreen-photo-sphere-btn,
|
|
||||||
.fullscreen-3d-btn {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
position: absolute;
|
|
||||||
right: 10px;
|
|
||||||
bottom: 10px;
|
|
||||||
cursor: pointer;
|
|
||||||
z-index: 100;
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-with-watermark {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.watermark {
|
|
||||||
position: absolute;
|
|
||||||
top: 10px;
|
|
||||||
left: 10px;
|
|
||||||
width: 50px;
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.psv-autorotate-button {
|
|
||||||
display: block !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.psv-menu-button {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
@ -1,36 +0,0 @@
|
|||||||
import { Media } from "@mt/common-types";
|
|
||||||
import { ImageMedia } from "./ImageMedia";
|
|
||||||
import { VideoMedia } from "./VideoMedia";
|
|
||||||
import { PhotoSphereMedia } from "./PhotoSphereMedia";
|
|
||||||
import { Object3DMedia } from "./Object3DMedia";
|
|
||||||
import { memo } from "react";
|
|
||||||
|
|
||||||
export const AttractionMedia = memo(
|
|
||||||
({ media }: { media: Media }) => {
|
|
||||||
const { type, url, watermarkUrl } = media;
|
|
||||||
|
|
||||||
if (!url) return null;
|
|
||||||
|
|
||||||
switch (type) {
|
|
||||||
case "IMAGE":
|
|
||||||
return (
|
|
||||||
<ImageMedia url={url} alt={media.url} watermarkUrl={watermarkUrl} />
|
|
||||||
);
|
|
||||||
case "VIDEO":
|
|
||||||
return <VideoMedia url={url} watermarkUrl={watermarkUrl} />;
|
|
||||||
case "PHOTO_SPHERE":
|
|
||||||
return <PhotoSphereMedia url={url} watermarkUrl={watermarkUrl} />;
|
|
||||||
case "OBJECT_3D":
|
|
||||||
return <Object3DMedia url={url} watermarkUrl={watermarkUrl} />;
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
({ media }, { media: newMedia }) => {
|
|
||||||
return (
|
|
||||||
media.url === newMedia.url &&
|
|
||||||
media.watermarkUrl === newMedia.watermarkUrl &&
|
|
||||||
media.type === newMedia.type
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
@ -1,25 +0,0 @@
|
|||||||
import cn from 'classnames';
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
import './AttractionMedia.css';
|
|
||||||
|
|
||||||
interface ImageMediaProps {
|
|
||||||
url: string;
|
|
||||||
alt: string;
|
|
||||||
watermarkUrl?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ImageMedia = ({ url, alt, watermarkUrl }: ImageMediaProps) => (
|
|
||||||
<>
|
|
||||||
<img
|
|
||||||
src={url}
|
|
||||||
alt={alt}
|
|
||||||
className={cn('widget-image', {
|
|
||||||
'media-with-watermark': watermarkUrl !== null,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
{watermarkUrl && (
|
|
||||||
<img src={watermarkUrl} alt="Watermark" className="watermark" />
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
@ -1,52 +0,0 @@
|
|||||||
import cn from "classnames";
|
|
||||||
import React, { useEffect, useState } from "react";
|
|
||||||
|
|
||||||
import "./AttractionMedia.css";
|
|
||||||
import ModelViewer from "../../model-viewer/ModelViewer";
|
|
||||||
import { Icons, useLightboxContext } from "@mt/components";
|
|
||||||
import { Object3DLightboxData } from "@mt/common-types";
|
|
||||||
|
|
||||||
interface Object3DMediaProps {
|
|
||||||
url: string;
|
|
||||||
watermarkUrl?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Object3DMedia = ({ url, watermarkUrl }: Object3DMediaProps) => {
|
|
||||||
// prettier-ignore
|
|
||||||
const { setData, openLightbox } = useLightboxContext<Object3DLightboxData>();
|
|
||||||
const [autoRotate, setAutoRotate] = useState(true);
|
|
||||||
|
|
||||||
const handle3DFullscreenOpen = () => {
|
|
||||||
setAutoRotate(false);
|
|
||||||
setData({
|
|
||||||
type: "OBJECT_3D",
|
|
||||||
modelUrl: url,
|
|
||||||
watermarkUrl,
|
|
||||||
});
|
|
||||||
openLightbox();
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setAutoRotate(true);
|
|
||||||
}, [url]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="widget-media__wrapper">
|
|
||||||
<div
|
|
||||||
className={cn("widget-3d-model", {
|
|
||||||
"media-with-watermark": watermarkUrl !== null,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<ModelViewer key={url} pathToModel={url} autoRotate={autoRotate} />
|
|
||||||
{watermarkUrl && (
|
|
||||||
<img src={watermarkUrl} alt="Watermark" className="watermark" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Icons.FullscreenIcon
|
|
||||||
className="fullscreen-3d-btn"
|
|
||||||
onPointerUp={() => handle3DFullscreenOpen()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,62 +0,0 @@
|
|||||||
import cn from "classnames";
|
|
||||||
import React, { useRef } from "react";
|
|
||||||
import { ReactPhotoSphereViewer } from "react-photo-sphere-viewer";
|
|
||||||
|
|
||||||
import { PhotoSphereLightboxData } from "@mt/common-types";
|
|
||||||
|
|
||||||
import "./AttractionMedia.css";
|
|
||||||
import { useLightboxContext } from "../../lightbox";
|
|
||||||
import { Icons } from "@mt/components";
|
|
||||||
|
|
||||||
interface PhotoSphereMediaProps {
|
|
||||||
url: string;
|
|
||||||
watermarkUrl?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const PhotoSphereMedia = ({
|
|
||||||
url,
|
|
||||||
watermarkUrl,
|
|
||||||
}: PhotoSphereMediaProps) => {
|
|
||||||
// prettier-ignore
|
|
||||||
const { setData, openLightbox } = useLightboxContext<PhotoSphereLightboxData>();
|
|
||||||
// react-photo-sphere-viewer doesn't have exported types, so here's a bit of a hardcoded piece
|
|
||||||
const photoSphereRef = useRef<any>(null);
|
|
||||||
|
|
||||||
const handlePhotoSphereFullscreenOpen = () => {
|
|
||||||
photoSphereRef.current?.stopAutoRotate();
|
|
||||||
setData({
|
|
||||||
type: "PHOTO_SPHERE",
|
|
||||||
imageUrl: url,
|
|
||||||
watermarkUrl,
|
|
||||||
});
|
|
||||||
openLightbox();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="widget-media__wrapper">
|
|
||||||
<ReactPhotoSphereViewer
|
|
||||||
ref={photoSphereRef}
|
|
||||||
key={url}
|
|
||||||
src={url}
|
|
||||||
height={"350px"}
|
|
||||||
width={"100%"}
|
|
||||||
container={cn("widget-media", {
|
|
||||||
"media-with-watermark": watermarkUrl !== null,
|
|
||||||
})}
|
|
||||||
moveInertia={false}
|
|
||||||
mousemove={true}
|
|
||||||
navbar={["autorotate", "zoom"]}
|
|
||||||
keyboard={false}
|
|
||||||
loadingTxt="Загрузка..."
|
|
||||||
/>
|
|
||||||
{watermarkUrl && (
|
|
||||||
<img src={watermarkUrl} alt="Watermark" className="watermark" />
|
|
||||||
)}
|
|
||||||
{/* the following is a workaround to open lightbox-like preview in the middle of the screen instead of the real fullscreen */}
|
|
||||||
<Icons.FullscreenIcon
|
|
||||||
className="fullscreen-photo-sphere-btn"
|
|
||||||
onPointerUp={() => handlePhotoSphereFullscreenOpen()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,26 +0,0 @@
|
|||||||
import cn from "classnames";
|
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
import "./AttractionMedia.css";
|
|
||||||
|
|
||||||
interface VideoMediaProps {
|
|
||||||
url: string;
|
|
||||||
watermarkUrl?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const VideoMedia = ({ url, watermarkUrl }: VideoMediaProps) => (
|
|
||||||
<>
|
|
||||||
<video
|
|
||||||
src={url}
|
|
||||||
className={cn("widget-video", {
|
|
||||||
"media-with-watermark": watermarkUrl !== null,
|
|
||||||
})}
|
|
||||||
autoPlay
|
|
||||||
loop
|
|
||||||
muted
|
|
||||||
/>
|
|
||||||
{watermarkUrl && (
|
|
||||||
<img src={watermarkUrl} alt="Watermark" className="watermark" />
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
@ -1,47 +0,0 @@
|
|||||||
// TODO: rewrite as css module
|
|
||||||
import styled from '@emotion/styled';
|
|
||||||
|
|
||||||
export const StyledDrawer = styled.div`
|
|
||||||
z-index: 1000;
|
|
||||||
position: absolute;
|
|
||||||
width: 290px;
|
|
||||||
height: 100%;
|
|
||||||
transition: all ease-in-out 0.3s;
|
|
||||||
|
|
||||||
transform: translateX(-100%);
|
|
||||||
|
|
||||||
color: #fff;
|
|
||||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0) 100%),
|
|
||||||
#806c59;
|
|
||||||
|
|
||||||
&.nav-widget--opened {
|
|
||||||
transform: translateX(0);
|
|
||||||
|
|
||||||
.toggle-btn {
|
|
||||||
transform: rotate(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 12px;
|
|
||||||
left: 310px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-btn {
|
|
||||||
width: 48px;
|
|
||||||
height: 48px;
|
|
||||||
display: block;
|
|
||||||
|
|
||||||
cursor: pointer;
|
|
||||||
margin-right: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-btn {
|
|
||||||
transform: rotate(180deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.order-btn-inverse {
|
|
||||||
transform: scale(1, -1);
|
|
||||||
}
|
|
||||||
`;
|
|
@ -1,53 +0,0 @@
|
|||||||
import cn from "classnames";
|
|
||||||
import { HTMLAttributes, ReactNode } from "react";
|
|
||||||
|
|
||||||
import { StyledDrawer } from "./Drawer.styles";
|
|
||||||
import { Icons } from "@mt/components";
|
|
||||||
import { Locale, LocaleSwitcher } from "@mt/i18n";
|
|
||||||
|
|
||||||
export interface DrawerProps extends HTMLAttributes<HTMLDivElement> {
|
|
||||||
onToggle: (isOpened: boolean) => void;
|
|
||||||
isOpen: boolean;
|
|
||||||
onHomeBtnClick?: () => void;
|
|
||||||
onLocaleChange: (locale: Locale) => void;
|
|
||||||
actions?: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: consider refactoring - drawer and controls should be separated
|
|
||||||
export function Drawer({
|
|
||||||
children,
|
|
||||||
isOpen,
|
|
||||||
onToggle,
|
|
||||||
onHomeBtnClick,
|
|
||||||
onLocaleChange,
|
|
||||||
actions,
|
|
||||||
...props
|
|
||||||
}: DrawerProps) {
|
|
||||||
return (
|
|
||||||
<StyledDrawer
|
|
||||||
className={cn("g-flex-column", { "nav-widget--opened": isOpen })}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
|
|
||||||
<div className="g-flex actions">
|
|
||||||
<div
|
|
||||||
className="action-btn toggle-btn"
|
|
||||||
onPointerUp={() => onToggle(!isOpen)}
|
|
||||||
>
|
|
||||||
<Icons.ArrowBtn />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isOpen && (
|
|
||||||
<div className="action-btn" onPointerUp={() => onHomeBtnClick?.()}>
|
|
||||||
<Icons.HomeBtn />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{actions}
|
|
||||||
|
|
||||||
<LocaleSwitcher onLocaleChange={onLocaleChange} />
|
|
||||||
</div>
|
|
||||||
</StyledDrawer>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1 +0,0 @@
|
|||||||
export { Drawer } from './Drawer';
|
|
@ -1,274 +0,0 @@
|
|||||||
import React, {
|
|
||||||
createContext,
|
|
||||||
useState,
|
|
||||||
useContext,
|
|
||||||
ReactNode,
|
|
||||||
useMemo,
|
|
||||||
useEffect,
|
|
||||||
} from "react";
|
|
||||||
import { geoMercator } from "d3-geo";
|
|
||||||
import { Coordinates, Track, uuid } from "@mt/common-types";
|
|
||||||
import { useNearStation, usePassedTrackIndex } from "./hooks";
|
|
||||||
import { AttractionGroup, MapData, StationOnMap } from "./map-widget.interface";
|
|
||||||
import { getMapPoint } from "./utils";
|
|
||||||
import { EMPTY_SETTING_VALUE, zeroCoordinates } from "./map-widget.constant";
|
|
||||||
import {
|
|
||||||
MapSettings,
|
|
||||||
MapWidgetContextType,
|
|
||||||
} from "./map-widget-context.interface";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
|
|
||||||
export const mapCanvasProps = {
|
|
||||||
style: {
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
},
|
|
||||||
width: 500,
|
|
||||||
height: 400,
|
|
||||||
};
|
|
||||||
|
|
||||||
// prettier-ignore
|
|
||||||
export const MapWidgetContext = createContext<MapWidgetContextType | undefined>(undefined);
|
|
||||||
|
|
||||||
export const MapWidgetProvider = ({ children }: { children: ReactNode }) => {
|
|
||||||
const [track, setTrack] = useState<Track | null>(null);
|
|
||||||
const [stations, setStations] = useState<StationOnMap[]>([]);
|
|
||||||
const [updatedStationIds, setUpdatedStationIds] = useState<uuid[]>([]);
|
|
||||||
const [attractionGroups, setAttractionGroups] = useState<AttractionGroup[]>(
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
const [rotateAngle, setRotateAngle] = useState<number>(0);
|
|
||||||
|
|
||||||
const [scale, setScale] = useState<number>(0);
|
|
||||||
const [fullScale, setFullScale] = useState<number>(0);
|
|
||||||
const [zoomedScale, setZoomedScale] = useState<number>(0);
|
|
||||||
|
|
||||||
const [center, setCenter] = useState(zeroCoordinates);
|
|
||||||
const [baseCenter, setBaseCenter] = useState(zeroCoordinates);
|
|
||||||
|
|
||||||
const [currentPosition, setCurrentPosition] = useState<Coordinates | null>(
|
|
||||||
null
|
|
||||||
);
|
|
||||||
|
|
||||||
const [isEditMode, setIsEditMode] = useState<boolean>(false);
|
|
||||||
const [isDragMode, setIsDragMode] = useState<boolean>(false);
|
|
||||||
|
|
||||||
const [initialSettingsData, setInitialSettingsData] =
|
|
||||||
useState<MapSettings>(EMPTY_SETTING_VALUE);
|
|
||||||
const [isSettingsDataChanged, setIsSettingsDataChanged] =
|
|
||||||
useState<boolean>(false);
|
|
||||||
|
|
||||||
const isMapDataChanged = useMemo(
|
|
||||||
() => isSettingsDataChanged || updatedStationIds.length > 0,
|
|
||||||
[isSettingsDataChanged, updatedStationIds]
|
|
||||||
);
|
|
||||||
|
|
||||||
const stationsMap = useMemo(
|
|
||||||
() => new Map(stations.map((station) => [station.id, station])),
|
|
||||||
[stations]
|
|
||||||
);
|
|
||||||
|
|
||||||
const middleTrackCoordinates: Coordinates | null = useMemo(() => {
|
|
||||||
if (!track?.length) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const middleTrackIndex = Math.floor(track.length / 2);
|
|
||||||
|
|
||||||
return track[middleTrackIndex];
|
|
||||||
}, [track]);
|
|
||||||
|
|
||||||
const settingsForm = useForm({
|
|
||||||
mode: "onChange",
|
|
||||||
reValidateMode: "onChange",
|
|
||||||
defaultValues: initialSettingsData,
|
|
||||||
});
|
|
||||||
useEffect(
|
|
||||||
() => settingsForm.reset(initialSettingsData),
|
|
||||||
[initialSettingsData]
|
|
||||||
);
|
|
||||||
|
|
||||||
const onMapDataFetched = (data: MapData) => {
|
|
||||||
setTrack(data.trackPoints);
|
|
||||||
setStations(data.stationsOnMap);
|
|
||||||
setAttractionGroups(data.touristAttractionGroupsOnMap);
|
|
||||||
setRotateAngle(data.mapRotateAngle);
|
|
||||||
|
|
||||||
setCenter(data.centerOfMapPoint);
|
|
||||||
setBaseCenter(data.centerOfMapPoint);
|
|
||||||
|
|
||||||
setScale(data.fullMapScale);
|
|
||||||
setFullScale(data.fullMapScale);
|
|
||||||
setZoomedScale(data.zoomedMapScale);
|
|
||||||
|
|
||||||
setInitialSettingsData({
|
|
||||||
rotateAngle: data.mapRotateAngle,
|
|
||||||
center: data.centerOfMapPoint,
|
|
||||||
fullScale: data.fullMapScale,
|
|
||||||
zoomedScale: data.zoomedMapScale,
|
|
||||||
});
|
|
||||||
|
|
||||||
setIsSettingsDataChanged(false);
|
|
||||||
setUpdatedStationIds([]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onSettingsFormChange = () => {
|
|
||||||
const formData = settingsForm.getValues();
|
|
||||||
const { center, rotateAngle, fullScale, zoomedScale, currentStationId } =
|
|
||||||
formData;
|
|
||||||
|
|
||||||
setBaseCenter(center);
|
|
||||||
setRotateAngle(rotateAngle);
|
|
||||||
setFullScale(fullScale);
|
|
||||||
setZoomedScale(zoomedScale);
|
|
||||||
|
|
||||||
if (currentStationId) {
|
|
||||||
const { pointOnMap } = stationsMap.get(currentStationId) as StationOnMap;
|
|
||||||
setCenter(pointOnMap);
|
|
||||||
setScale(zoomedScale);
|
|
||||||
setCurrentPosition(pointOnMap);
|
|
||||||
setIsDragMode(false);
|
|
||||||
} else {
|
|
||||||
setCenter(center);
|
|
||||||
setScale(fullScale);
|
|
||||||
setCurrentPosition(middleTrackCoordinates);
|
|
||||||
}
|
|
||||||
|
|
||||||
updateMapDataChanged(formData);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onMapCenterMoved = (center: Coordinates) => {
|
|
||||||
setBaseCenter(center);
|
|
||||||
setCenter(center);
|
|
||||||
settingsForm.setValue("center", center, { shouldDirty: true });
|
|
||||||
|
|
||||||
updateMapDataChanged(settingsForm.getValues());
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateMapDataChanged = (data: MapSettings) => {
|
|
||||||
const { rotateAngle, center, fullScale, zoomedScale } = data;
|
|
||||||
|
|
||||||
setIsSettingsDataChanged(
|
|
||||||
JSON.stringify({
|
|
||||||
rotateAngle,
|
|
||||||
center,
|
|
||||||
fullScale,
|
|
||||||
zoomedScale,
|
|
||||||
}) !== JSON.stringify(initialSettingsData)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onStationUpdate: MapWidgetContextType["onStationUpdate"] = (
|
|
||||||
stationId,
|
|
||||||
{ labelOffset, labelAlignment }
|
|
||||||
) => {
|
|
||||||
const updatedStation = {
|
|
||||||
...(stationsMap.get(stationId) as StationOnMap),
|
|
||||||
...(labelOffset && { labelOffset }),
|
|
||||||
...(labelAlignment && { labelAlignment }),
|
|
||||||
};
|
|
||||||
|
|
||||||
setStations((stations) =>
|
|
||||||
stations.map((station) =>
|
|
||||||
station.id === stationId ? updatedStation : station
|
|
||||||
)
|
|
||||||
);
|
|
||||||
setUpdatedStationIds((ids) => [...ids, stationId]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getUpdatedStations = () => {
|
|
||||||
return updatedStationIds.reduce((acc: Record<uuid, any>, id: uuid) => {
|
|
||||||
const { labelAlignment, labelOffset } = stationsMap.get(
|
|
||||||
id
|
|
||||||
) as StationOnMap;
|
|
||||||
|
|
||||||
acc[id] = {
|
|
||||||
textAlignment: labelAlignment,
|
|
||||||
mapOffsets: labelOffset,
|
|
||||||
};
|
|
||||||
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
};
|
|
||||||
|
|
||||||
const projection = useMemo(() => {
|
|
||||||
const { width, height } = mapCanvasProps;
|
|
||||||
|
|
||||||
return geoMercator()
|
|
||||||
.translate([width / 2, height / 2])
|
|
||||||
.center(getMapPoint(center))
|
|
||||||
.scale(scale);
|
|
||||||
}, [center, scale]);
|
|
||||||
|
|
||||||
const { passedTrackIndex } = usePassedTrackIndex(track, currentPosition);
|
|
||||||
const { currentStation, nextStation, isOnStation } = useNearStation(
|
|
||||||
currentPosition,
|
|
||||||
stations,
|
|
||||||
passedTrackIndex
|
|
||||||
);
|
|
||||||
|
|
||||||
// Bind map center and zoom to currentStation in not EditMode
|
|
||||||
useEffect(() => {
|
|
||||||
if (isEditMode) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentStation) {
|
|
||||||
const { pointOnMap } = currentStation;
|
|
||||||
setCenter(pointOnMap);
|
|
||||||
setScale(zoomedScale);
|
|
||||||
} else {
|
|
||||||
setCenter(baseCenter);
|
|
||||||
setScale(fullScale);
|
|
||||||
}
|
|
||||||
}, [currentStation]);
|
|
||||||
|
|
||||||
const contextValue = {
|
|
||||||
track,
|
|
||||||
center,
|
|
||||||
rotateAngle,
|
|
||||||
projection,
|
|
||||||
attractionGroups,
|
|
||||||
stations,
|
|
||||||
currentPosition,
|
|
||||||
middleTrackCoordinates,
|
|
||||||
passedTrackIndex,
|
|
||||||
currentStation,
|
|
||||||
isOnStation,
|
|
||||||
nextStation,
|
|
||||||
|
|
||||||
setCurrentPosition,
|
|
||||||
|
|
||||||
isDragMode,
|
|
||||||
setIsDragMode,
|
|
||||||
isEditMode,
|
|
||||||
setIsEditMode,
|
|
||||||
|
|
||||||
onMapDataFetched,
|
|
||||||
settingsForm,
|
|
||||||
onSettingsFormChange,
|
|
||||||
onMapCenterMoved,
|
|
||||||
onStationUpdate,
|
|
||||||
getUpdatedStations,
|
|
||||||
|
|
||||||
isMapDataChanged,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<MapWidgetContext.Provider value={contextValue}>
|
|
||||||
{children}
|
|
||||||
</MapWidgetContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useMapWidgetContext = function (): MapWidgetContextType {
|
|
||||||
const context = useContext(MapWidgetContext);
|
|
||||||
|
|
||||||
if (!context) {
|
|
||||||
throw new Error(
|
|
||||||
"useMapWidgetContext must be used within a MapWidgetProvider"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return context;
|
|
||||||
};
|
|
@ -1,25 +0,0 @@
|
|||||||
import { TrackAttractions, TrackLine, TrackStations, TramMarker } from '../index';
|
|
||||||
import { getMapPoint } from '../../utils';
|
|
||||||
import { useMapWidgetContext } from '../../MapWidgetContext';
|
|
||||||
|
|
||||||
export const MapContent = () => {
|
|
||||||
const { rotateAngle, isEditMode, currentPosition, currentStation, nextStation } =
|
|
||||||
useMapWidgetContext();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<g className="g-transform-origin__center" style={{ transform: `rotate(${rotateAngle}deg)` }}>
|
|
||||||
<TrackLine />
|
|
||||||
|
|
||||||
<TrackAttractions />
|
|
||||||
|
|
||||||
<TrackStations />
|
|
||||||
|
|
||||||
{!isEditMode && currentPosition && nextStation && (
|
|
||||||
<TramMarker
|
|
||||||
coordinates={getMapPoint(currentPosition)}
|
|
||||||
nextStopPoint={(currentStation ?? nextStation).pointOnMap}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</g>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,4 +0,0 @@
|
|||||||
.mapWidget {
|
|
||||||
position: relative;
|
|
||||||
z-index: 100;
|
|
||||||
}
|
|
@ -1,65 +0,0 @@
|
|||||||
import {
|
|
||||||
ComposableMap,
|
|
||||||
ZoomableGroup,
|
|
||||||
ZoomableGroupProps,
|
|
||||||
} from "react-simple-maps";
|
|
||||||
import styles from "./MapWidget.module.css";
|
|
||||||
import { mapCanvasProps, useMapWidgetContext } from "../../MapWidgetContext";
|
|
||||||
import { useState, FC, ReactNode } from "react";
|
|
||||||
import { MapContent } from "./MapContent";
|
|
||||||
|
|
||||||
// Create wrapper components to handle type issues
|
|
||||||
const ComposableMapWrapper: FC<any> = (props) => {
|
|
||||||
// @ts-ignore - Ignore type issues with the ComposableMap component
|
|
||||||
return <ComposableMap {...props} />;
|
|
||||||
};
|
|
||||||
|
|
||||||
const ZoomableGroupWrapper: FC<ZoomableGroupProps> = (props) => {
|
|
||||||
// @ts-ignore - Ignore type issues with the ZoomableGroup component
|
|
||||||
return <ZoomableGroup {...props} />;
|
|
||||||
};
|
|
||||||
|
|
||||||
// default coordinates for 3a route: 59.943, 30.331
|
|
||||||
export const MapWidget = () => {
|
|
||||||
const { onMapCenterMoved, projection, isDragMode, rotateAngle } =
|
|
||||||
useMapWidgetContext();
|
|
||||||
const [key, setKey] = useState(42);
|
|
||||||
|
|
||||||
const handleMoveEnd: ZoomableGroupProps["onMoveEnd"] = (e, d3Zoom) => {
|
|
||||||
const { PI, cos, sin } = Math;
|
|
||||||
const { x, y } = d3Zoom.transform;
|
|
||||||
const { width, height } = mapCanvasProps;
|
|
||||||
|
|
||||||
const alpha = (-rotateAngle * PI) / 180;
|
|
||||||
|
|
||||||
const x1 = x * cos(alpha) - y * sin(alpha);
|
|
||||||
const y1 = x * sin(alpha) + y * cos(alpha);
|
|
||||||
|
|
||||||
const cX = width / 2 - x1;
|
|
||||||
const cY = height / 2 - y1;
|
|
||||||
|
|
||||||
const [lon, lat] = projection.invert?.([cX, cY]) ?? [0, 0];
|
|
||||||
|
|
||||||
onMapCenterMoved({ lon, lat });
|
|
||||||
setKey(-key);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ComposableMapWrapper
|
|
||||||
projection={projection as any}
|
|
||||||
className={styles.mapWidget}
|
|
||||||
{...mapCanvasProps}
|
|
||||||
>
|
|
||||||
<ZoomableGroupWrapper
|
|
||||||
key={key}
|
|
||||||
center={projection.center()}
|
|
||||||
onMoveEnd={handleMoveEnd}
|
|
||||||
filterZoomEvent={() => isDragMode}
|
|
||||||
minZoom={1}
|
|
||||||
maxZoom={1}
|
|
||||||
>
|
|
||||||
<MapContent />
|
|
||||||
</ZoomableGroupWrapper>
|
|
||||||
</ComposableMapWrapper>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1 +0,0 @@
|
|||||||
export * from './MapWidget';
|
|
@ -1,41 +0,0 @@
|
|||||||
.markerLarge,
|
|
||||||
.markerSmall {
|
|
||||||
overflow: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
.markerLarge {
|
|
||||||
width: 23px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.markerLarge .counter {
|
|
||||||
transform: translate(30%, 15%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.markerSmall {
|
|
||||||
width: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.markerSmall .counter {
|
|
||||||
transform: translate(50%, -25%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.counter {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
right: 0;
|
|
||||||
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
line-height: 8px;
|
|
||||||
text-align: center;
|
|
||||||
|
|
||||||
border-radius: 50%;
|
|
||||||
background-color: #896f58;
|
|
||||||
font-size: 0.3rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
@ -1,40 +0,0 @@
|
|||||||
import { Point, Marker } from "react-simple-maps";
|
|
||||||
import { Icons } from "@mt/components";
|
|
||||||
import { AttractionGroupIconSizeType } from "@mt/common-types";
|
|
||||||
|
|
||||||
import styles from "./AttractionMarker.module.css";
|
|
||||||
import cn from "classnames";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
coordinates: Point;
|
|
||||||
rotate: number;
|
|
||||||
size: AttractionGroupIconSizeType;
|
|
||||||
counter?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AttractionMarker = ({
|
|
||||||
coordinates,
|
|
||||||
counter = 0,
|
|
||||||
rotate,
|
|
||||||
size,
|
|
||||||
}: Props) => {
|
|
||||||
return (
|
|
||||||
<Marker coordinates={coordinates}>
|
|
||||||
<foreignObject
|
|
||||||
className={cn({
|
|
||||||
[styles.markerLarge]: size === "LARGE",
|
|
||||||
[styles.markerSmall]: size === "SMALL",
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="g-transform-origin__center"
|
|
||||||
style={{ transform: `rotate(${rotate}deg)` }}
|
|
||||||
>
|
|
||||||
<Icons.AttractionIcon className={styles.icon} />
|
|
||||||
|
|
||||||
{counter > 1 && <div className={styles.counter}>{counter}</div>}
|
|
||||||
</div>
|
|
||||||
</foreignObject>
|
|
||||||
</Marker>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,25 +0,0 @@
|
|||||||
import { AttractionMarker } from "./AttractionMarker";
|
|
||||||
import { getMapPoint } from "../../utils";
|
|
||||||
import { useMapWidgetContext } from "../../MapWidgetContext";
|
|
||||||
|
|
||||||
export const TrackAttractions = () => {
|
|
||||||
const { attractionGroups, rotateAngle } = useMapWidgetContext();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{attractionGroups.map((group) => (
|
|
||||||
<AttractionMarker
|
|
||||||
key={
|
|
||||||
group.touristAttractionsOnMap[0]?.id ||
|
|
||||||
`${group.pointOnMap.lat}:${group.pointOnMap.lon}`
|
|
||||||
}
|
|
||||||
coordinates={getMapPoint(group.pointOnMap)}
|
|
||||||
// Inverse angle to compensate map rotation
|
|
||||||
rotate={-rotateAngle}
|
|
||||||
counter={group.touristAttractionsOnMap.length}
|
|
||||||
size={group.iconSize}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1 +0,0 @@
|
|||||||
export * from './TrackAttractions';
|
|
@ -1,37 +0,0 @@
|
|||||||
import { useMemo } from "react";
|
|
||||||
import { Line, Point } from "react-simple-maps";
|
|
||||||
import { getMapPoint } from "../utils";
|
|
||||||
import { useMapWidgetContext } from "../MapWidgetContext";
|
|
||||||
import { zeroCoordinates } from "../map-widget.constant";
|
|
||||||
|
|
||||||
const passedTrackColor = "#ed1c24";
|
|
||||||
const trackColor = "#cccccc";
|
|
||||||
|
|
||||||
export const TrackLine = () => {
|
|
||||||
const { track, passedTrackIndex, currentPosition } = useMapWidgetContext();
|
|
||||||
const mappedTrack: Point[] = useMemo(
|
|
||||||
() => (track ? track.map(({ lat, lon }) => [lon, lat]) : []),
|
|
||||||
[track]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Line
|
|
||||||
coordinates={mappedTrack}
|
|
||||||
strokeWidth={2.5}
|
|
||||||
strokeLinecap="round"
|
|
||||||
stroke={trackColor}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Line
|
|
||||||
coordinates={[
|
|
||||||
...mappedTrack.slice(0, passedTrackIndex),
|
|
||||||
getMapPoint(currentPosition ?? zeroCoordinates),
|
|
||||||
]}
|
|
||||||
strokeWidth={3.5}
|
|
||||||
strokeLinecap="round"
|
|
||||||
stroke={passedTrackColor}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,8 +0,0 @@
|
|||||||
// TODO: resolve circular deps
|
|
||||||
import { StationLabelContent, StationLabelContentProps } from './StationLabelContent';
|
|
||||||
|
|
||||||
export const StationLabel = ({ station }: StationLabelContentProps) => (
|
|
||||||
<foreignObject className="track-station" {...station.labelOffset}>
|
|
||||||
<StationLabelContent station={station} />
|
|
||||||
</foreignObject>
|
|
||||||
);
|
|
@ -1,55 +0,0 @@
|
|||||||
import { HTMLAttributes, ReactNode, useContext } from 'react';
|
|
||||||
|
|
||||||
import { StationOnMap, TransportIcon, useMapWidgetContext } from '@mt/components';
|
|
||||||
import { OnMapTextAlignment } from '@mt/common-types';
|
|
||||||
import { LocalizationContext } from '@mt/i18n';
|
|
||||||
|
|
||||||
export interface StationLabelContentProps extends HTMLAttributes<HTMLDivElement> {
|
|
||||||
station: StationOnMap;
|
|
||||||
children?: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
type TextAlign = Lowercase<OnMapTextAlignment>;
|
|
||||||
|
|
||||||
export const StationLabelContent = ({
|
|
||||||
station,
|
|
||||||
children,
|
|
||||||
className = '',
|
|
||||||
}: StationLabelContentProps) => {
|
|
||||||
const { locale } = useContext(LocalizationContext);
|
|
||||||
const { rotateAngle } = useMapWidgetContext();
|
|
||||||
|
|
||||||
const { pointOnMap, labelAlignment, iconUrl, shortName, name, transferStationInfos } = station;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
id={`${pointOnMap.lat}:${pointOnMap.lon}`}
|
|
||||||
className={`track-station__wrapper ${className}`}
|
|
||||||
style={{
|
|
||||||
textAlign: labelAlignment as TextAlign,
|
|
||||||
// Inverse angle to compensate map rotation
|
|
||||||
transform: `rotate(${-rotateAngle}deg)`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="track-station__label">
|
|
||||||
{iconUrl && <img className="track-station__icon" src={iconUrl} />}
|
|
||||||
{(shortName ?? name).ru}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="track-station__transfers-wrapper">
|
|
||||||
{transferStationInfos.map((transfer) => (
|
|
||||||
<div className="track-station__label" key={transfer.name.ru}>
|
|
||||||
<TransportIcon type={transfer.type} className="transport-icon" />
|
|
||||||
{transfer.name.ru}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="track-station__label-locale">
|
|
||||||
{locale === 'zh' ? (shortName ?? name).zh : (shortName ?? name).en}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,63 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import Draggable, { DraggableData, DraggableEvent } from 'react-draggable';
|
|
||||||
import { ButtonGroup, IconButton } from '@mui/material';
|
|
||||||
import AlignHorizontalLeftRoundedIcon from '@mui/icons-material/AlignHorizontalLeftRounded';
|
|
||||||
import AlignHorizontalCenterRoundedIcon from '@mui/icons-material/AlignHorizontalCenterRounded';
|
|
||||||
import AlignHorizontalRightRoundedIcon from '@mui/icons-material/AlignHorizontalRightRounded';
|
|
||||||
|
|
||||||
// TODO: resolve circular deps
|
|
||||||
import { OnMapOffset, OnMapTextAlignment } from '@mt/common-types';
|
|
||||||
import { useMapWidgetContext } from '@mt/components';
|
|
||||||
import { StationLabelContent, StationLabelContentProps } from './StationLabelContent';
|
|
||||||
|
|
||||||
const CONTAINER_WIDTH = 1343;
|
|
||||||
const SVG_WIDTH = 500;
|
|
||||||
|
|
||||||
export const StationLabelEdit = ({ station }: StationLabelContentProps) => {
|
|
||||||
const { onStationUpdate } = useMapWidgetContext();
|
|
||||||
const { id, labelOffset } = station;
|
|
||||||
|
|
||||||
const [calculatedOffset] = useState<OnMapOffset>(labelOffset);
|
|
||||||
const [dragStartPoint, setDragStartPoint] = useState<OnMapOffset>({ x: -1, y: -1 });
|
|
||||||
|
|
||||||
const onDragStart = () => setDragStartPoint(calculatedOffset);
|
|
||||||
|
|
||||||
const onDragStop = (e: DraggableEvent, { lastX, lastY }: DraggableData) => {
|
|
||||||
const labelOffset = {
|
|
||||||
x: dragStartPoint.x + lastX,
|
|
||||||
y: dragStartPoint.y + lastY,
|
|
||||||
};
|
|
||||||
|
|
||||||
onStationUpdate(id, { labelOffset });
|
|
||||||
};
|
|
||||||
|
|
||||||
const setAlignment = (labelAlignment: OnMapTextAlignment): void =>
|
|
||||||
onStationUpdate(id, { labelAlignment });
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Draggable
|
|
||||||
onStart={onDragStart}
|
|
||||||
onStop={onDragStop}
|
|
||||||
scale={CONTAINER_WIDTH / SVG_WIDTH}
|
|
||||||
positionOffset={calculatedOffset}
|
|
||||||
>
|
|
||||||
<foreignObject className="track-station">
|
|
||||||
<StationLabelContent station={station} className="editable">
|
|
||||||
<ButtonGroup size="small" className="align-btns-group">
|
|
||||||
<IconButton size="small" onClick={() => setAlignment('LEFT')}>
|
|
||||||
<AlignHorizontalLeftRoundedIcon />
|
|
||||||
</IconButton>
|
|
||||||
|
|
||||||
<IconButton size="small" onClick={() => setAlignment('CENTER')}>
|
|
||||||
<AlignHorizontalCenterRoundedIcon />
|
|
||||||
</IconButton>
|
|
||||||
|
|
||||||
<IconButton size="small" onClick={() => setAlignment('RIGHT')}>
|
|
||||||
<AlignHorizontalRightRoundedIcon />
|
|
||||||
</IconButton>
|
|
||||||
</ButtonGroup>
|
|
||||||
</StationLabelContent>
|
|
||||||
</foreignObject>
|
|
||||||
</Draggable>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,68 +0,0 @@
|
|||||||
/* foreignObject */
|
|
||||||
.track-station {
|
|
||||||
overflow: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-station__wrapper {
|
|
||||||
transform-origin: left top;
|
|
||||||
transform-box: fill-box;
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-station__wrapper.editable {
|
|
||||||
cursor: move;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-station__label,
|
|
||||||
.track-station__label-locale {
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-station__label {
|
|
||||||
color: #ffffff;
|
|
||||||
font-size: 0.32rem;
|
|
||||||
line-height: 1;
|
|
||||||
letter-spacing: initial;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-station__label-locale {
|
|
||||||
color: #cbcbcb;
|
|
||||||
font-size: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-station__transfers-wrapper:not(:empty) {
|
|
||||||
margin: 1.5px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.align-btns-group {
|
|
||||||
position: absolute;
|
|
||||||
left: 50%;
|
|
||||||
transform: translate(-50%, 0);
|
|
||||||
background-color: #ffffff;
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-station__wrapper:hover .align-btns-group {
|
|
||||||
display: flex !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.align-btns-group > button {
|
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.transport-icon,
|
|
||||||
.track-station__icon {
|
|
||||||
display: inline-block;
|
|
||||||
margin-right: 1px;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
.transport-icon,
|
|
||||||
.track-station__icon,
|
|
||||||
.track-station svg {
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
}
|
|
@ -1,71 +0,0 @@
|
|||||||
import { Marker } from "react-simple-maps";
|
|
||||||
// TODO: resolve circular deps
|
|
||||||
import type { uuid } from "@mt/common-types";
|
|
||||||
|
|
||||||
import { getMapPoint } from "../../utils";
|
|
||||||
|
|
||||||
import "./TrackStations.css";
|
|
||||||
import { StationLabelEdit } from "./StationLabelEdit";
|
|
||||||
import { useMapWidgetContext } from "../../MapWidgetContext";
|
|
||||||
import { StationLabel } from "./StationLabel";
|
|
||||||
|
|
||||||
const colors = {
|
|
||||||
black: "#000000",
|
|
||||||
red: "#ed1c24",
|
|
||||||
grey: "#cccccc",
|
|
||||||
yellow: "#fcd500",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const TrackStations = () => {
|
|
||||||
const {
|
|
||||||
stations,
|
|
||||||
currentStation,
|
|
||||||
passedTrackIndex,
|
|
||||||
isOnStation,
|
|
||||||
isEditMode,
|
|
||||||
} = useMapWidgetContext();
|
|
||||||
const isTerminalStation = (index: number) =>
|
|
||||||
index === 0 || index === stations.length - 1;
|
|
||||||
|
|
||||||
const getStationFill = (
|
|
||||||
id: uuid,
|
|
||||||
trackIndex: number,
|
|
||||||
index: number
|
|
||||||
): string => {
|
|
||||||
if (isOnStation && currentStation?.id === id) {
|
|
||||||
return colors.yellow;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isTerminalStation(index)) {
|
|
||||||
return colors.black;
|
|
||||||
}
|
|
||||||
|
|
||||||
return trackIndex <= passedTrackIndex ? colors.red : colors.grey;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStationStroke = (index: number) => {
|
|
||||||
if (index === 0) return colors.red;
|
|
||||||
if (index === stations.length - 1) return colors.grey;
|
|
||||||
return colors.black;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{stations.map((it, index) => (
|
|
||||||
<Marker key={it.id} coordinates={getMapPoint(it.pointOnMap)}>
|
|
||||||
<circle
|
|
||||||
fill={getStationFill(it.id, it.pointOnMap.trackIndex, index)}
|
|
||||||
stroke={getStationStroke(index)}
|
|
||||||
r={3.5}
|
|
||||||
strokeWidth={isTerminalStation(index) ? 2 : 1.5}
|
|
||||||
/>
|
|
||||||
{isEditMode ? (
|
|
||||||
<StationLabelEdit station={it} />
|
|
||||||
) : (
|
|
||||||
<StationLabel station={it} />
|
|
||||||
)}
|
|
||||||
</Marker>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1 +0,0 @@
|
|||||||
export * from './TrackStations';
|
|
@ -1,36 +0,0 @@
|
|||||||
.icon {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.iconContainer {
|
|
||||||
position: relative;
|
|
||||||
overflow: visible;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: #e20613;
|
|
||||||
padding: 3px;
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
transform: translate(16px, 16px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.flipped {
|
|
||||||
transform: translate(-48px, -48px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.iconContainer:after {
|
|
||||||
content: '';
|
|
||||||
background: linear-gradient(135deg, #e20713, #00000000);
|
|
||||||
clip-path: polygon(0 0, 50% 100%, 100% 50%);
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
transform-origin: bottom right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.flipped:after {
|
|
||||||
transform: translate(-50%, -50%) rotate(180deg);
|
|
||||||
}
|
|
@ -1,52 +0,0 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
import { getIntersection, getIntersectionArea } from "../../utils";
|
|
||||||
import { Marker, Point } from "react-simple-maps";
|
|
||||||
import { Coordinates } from "@mt/common-types";
|
|
||||||
import cn from "classnames";
|
|
||||||
import styles from "./TramMarker.module.css";
|
|
||||||
import { Icons, useMapWidgetContext } from "@mt/components";
|
|
||||||
|
|
||||||
interface TramMarkerProps {
|
|
||||||
coordinates: Point;
|
|
||||||
nextStopPoint: Coordinates;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TramMarker = ({ coordinates, nextStopPoint }: TramMarkerProps) => {
|
|
||||||
const [flipped, setFlipped] = useState(false);
|
|
||||||
|
|
||||||
const { rotateAngle } = useMapWidgetContext();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const tramRect = document
|
|
||||||
.getElementById("tram-marker")
|
|
||||||
?.getBoundingClientRect();
|
|
||||||
const nextStopRect = document
|
|
||||||
.getElementById(`${nextStopPoint.lat}:${nextStopPoint.lon}`)
|
|
||||||
?.getBoundingClientRect();
|
|
||||||
|
|
||||||
if (tramRect && nextStopRect) {
|
|
||||||
// prettier-ignore
|
|
||||||
const hasIntersection = getIntersection(tramRect, nextStopRect) !== null;
|
|
||||||
const intersectionArea = getIntersectionArea(tramRect, nextStopRect);
|
|
||||||
|
|
||||||
if (hasIntersection && intersectionArea > 150) {
|
|
||||||
setFlipped((isFlipped) => !isFlipped);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [coordinates]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Marker coordinates={coordinates} id="tram-marker">
|
|
||||||
<foreignObject
|
|
||||||
className={cn(styles.iconContainer, { [styles.flipped]: flipped })}
|
|
||||||
>
|
|
||||||
<Icons.TramMarkerIcon
|
|
||||||
className={`${styles.icon} g-transform-origin__center`}
|
|
||||||
// Inverse angle to compensate map rotation
|
|
||||||
style={{ transform: `rotate(${-rotateAngle}deg)` }}
|
|
||||||
/>
|
|
||||||
</foreignObject>
|
|
||||||
</Marker>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1 +0,0 @@
|
|||||||
export * from './TramMarker';
|
|
@ -1,5 +0,0 @@
|
|||||||
export * from './TramMarker';
|
|
||||||
export * from './TrackLine';
|
|
||||||
export * from './TrackStations';
|
|
||||||
export * from './TrackAttractions';
|
|
||||||
export * from './MapWidget';
|
|
@ -1,2 +0,0 @@
|
|||||||
export * from './usePassedTrackIndex';
|
|
||||||
export * from './useNearStation';
|
|
@ -1,61 +0,0 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
import { StationOnMap } from "../map-widget.interface";
|
|
||||||
import { getDistance } from "../utils";
|
|
||||||
import { Coordinates } from "@mt/common-types";
|
|
||||||
|
|
||||||
const ZOOM_DISTANCE = 100;
|
|
||||||
const ON_STATION_DISTANCE = 15;
|
|
||||||
|
|
||||||
export function useNearStation(
|
|
||||||
currentPosition: Coordinates | null,
|
|
||||||
stations: StationOnMap[],
|
|
||||||
passedTrackIndex: number
|
|
||||||
) {
|
|
||||||
const [nextStation, setNextStation] = useState<StationOnMap | null>(null);
|
|
||||||
const [currentStation, setCurrentStation] = useState<StationOnMap | null>(
|
|
||||||
null
|
|
||||||
);
|
|
||||||
const [isOnStation, setIsOnStation] = useState<boolean>(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!currentPosition) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextStationIndex = stations.findIndex(
|
|
||||||
({ pointOnMap }) => pointOnMap.trackIndex > passedTrackIndex
|
|
||||||
);
|
|
||||||
|
|
||||||
const nextStation = stations[nextStationIndex] ?? null;
|
|
||||||
const prevStation = stations[nextStationIndex - 1] ?? null;
|
|
||||||
|
|
||||||
const distanceToNext = nextStation
|
|
||||||
? getDistance(currentPosition, nextStation.pointOnMap)
|
|
||||||
: ZOOM_DISTANCE + 1;
|
|
||||||
|
|
||||||
setNextStation(nextStation);
|
|
||||||
|
|
||||||
if (distanceToNext <= ZOOM_DISTANCE) {
|
|
||||||
setCurrentStation(nextStation);
|
|
||||||
setIsOnStation(distanceToNext <= ON_STATION_DISTANCE);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const distanceToPrev = prevStation
|
|
||||||
? getDistance(currentPosition, prevStation.pointOnMap)
|
|
||||||
: ZOOM_DISTANCE + 1;
|
|
||||||
|
|
||||||
if (distanceToPrev <= ZOOM_DISTANCE) {
|
|
||||||
setCurrentStation(prevStation);
|
|
||||||
setIsOnStation(distanceToPrev <= ON_STATION_DISTANCE);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setCurrentStation(null);
|
|
||||||
setIsOnStation(false);
|
|
||||||
}, [currentPosition, stations, passedTrackIndex]);
|
|
||||||
|
|
||||||
return { currentStation, nextStation, isOnStation };
|
|
||||||
}
|
|
@ -1,61 +0,0 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
import { Coordinates, Track } from "@mt/common-types";
|
|
||||||
|
|
||||||
import { getDistance, getPointDeviation } from "../utils";
|
|
||||||
|
|
||||||
const APPROXIMATE_DISTANCE = 15; // [meters] half of tramway length (~30 meters)
|
|
||||||
|
|
||||||
export function usePassedTrackIndex(
|
|
||||||
track: Track | null,
|
|
||||||
currentPosition: Coordinates | null
|
|
||||||
) {
|
|
||||||
const [passedTrackIndex, setPassedTrackIndex] = useState<number>(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!track || !currentPosition) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let minDistance = getDistance(track[0], currentPosition);
|
|
||||||
let newPassedIndex = 0;
|
|
||||||
|
|
||||||
for (let i = 1; i < track.length; i++) {
|
|
||||||
const distance = getDistance(track[i], currentPosition);
|
|
||||||
|
|
||||||
if (distance < minDistance) {
|
|
||||||
newPassedIndex = i;
|
|
||||||
minDistance = distance;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Is current position more than APPROXIMATE_DISTANCE far from found track point
|
|
||||||
* we need to check that we really reach newPassedIndex. If not — should decrement index
|
|
||||||
*/
|
|
||||||
if (
|
|
||||||
getDistance(track[newPassedIndex], currentPosition) > APPROXIMATE_DISTANCE
|
|
||||||
) {
|
|
||||||
const prevIndex = Math.max(newPassedIndex - 1, 0);
|
|
||||||
const nextIndex = Math.min(newPassedIndex + 1, track.length - 1);
|
|
||||||
|
|
||||||
const leftDeviation = getPointDeviation(
|
|
||||||
track[prevIndex],
|
|
||||||
track[newPassedIndex], // Ближайшая точка трека
|
|
||||||
currentPosition
|
|
||||||
);
|
|
||||||
const rightDeviation = getPointDeviation(
|
|
||||||
track[newPassedIndex], // Ближайшая точка трека
|
|
||||||
track[nextIndex],
|
|
||||||
currentPosition
|
|
||||||
);
|
|
||||||
|
|
||||||
if (leftDeviation >= rightDeviation) {
|
|
||||||
newPassedIndex--;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setPassedTrackIndex(newPassedIndex);
|
|
||||||
}, [track, currentPosition]);
|
|
||||||
|
|
||||||
return { passedTrackIndex };
|
|
||||||
}
|
|
@ -1,5 +0,0 @@
|
|||||||
export * from './map-widget.constant';
|
|
||||||
export * from './map-widget.interface';
|
|
||||||
export * from './components';
|
|
||||||
export * from './MapWidgetContext';
|
|
||||||
export * from './map-widget-context.interface';
|
|
@ -1,50 +0,0 @@
|
|||||||
import { Coordinates, SetState, uuid } from "@mt/common-types";
|
|
||||||
import { MapData, StationOnMap } from "@mt/components";
|
|
||||||
|
|
||||||
import { UseFormReturn } from "react-hook-form";
|
|
||||||
import { RouteStation } from "@mt/common-types";
|
|
||||||
import { GeoProjection } from "d3-geo";
|
|
||||||
|
|
||||||
export interface MapWidgetContextType {
|
|
||||||
// External data
|
|
||||||
track: MapData["trackPoints"] | null;
|
|
||||||
stations: MapData["stationsOnMap"];
|
|
||||||
attractionGroups: MapData["touristAttractionGroupsOnMap"];
|
|
||||||
rotateAngle: MapData["mapRotateAngle"];
|
|
||||||
center: Coordinates;
|
|
||||||
projection: GeoProjection;
|
|
||||||
currentPosition: Coordinates | null;
|
|
||||||
middleTrackCoordinates: Coordinates | null;
|
|
||||||
passedTrackIndex: number;
|
|
||||||
currentStation: StationOnMap | null;
|
|
||||||
nextStation: StationOnMap | null;
|
|
||||||
isOnStation: boolean;
|
|
||||||
|
|
||||||
// Calculated data
|
|
||||||
setCurrentPosition: SetState<Coordinates | null>;
|
|
||||||
|
|
||||||
isDragMode: boolean;
|
|
||||||
|
|
||||||
setIsDragMode: SetState<boolean>;
|
|
||||||
isEditMode: boolean;
|
|
||||||
setIsEditMode: SetState<boolean>;
|
|
||||||
|
|
||||||
onMapDataFetched: (payload: MapData) => void;
|
|
||||||
settingsForm: UseFormReturn<MapSettings>;
|
|
||||||
onSettingsFormChange: () => void;
|
|
||||||
onMapCenterMoved: (center: Coordinates) => void;
|
|
||||||
onStationUpdate: (
|
|
||||||
stationId: uuid,
|
|
||||||
data: Partial<Pick<StationOnMap, "labelAlignment" | "labelOffset">>
|
|
||||||
) => void;
|
|
||||||
getUpdatedStations: () => Partial<RouteStation>;
|
|
||||||
isMapDataChanged: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MapSettings {
|
|
||||||
rotateAngle: number;
|
|
||||||
fullScale: number;
|
|
||||||
zoomedScale: number;
|
|
||||||
center: Coordinates;
|
|
||||||
currentStationId?: string;
|
|
||||||
}
|
|
@ -1,10 +0,0 @@
|
|||||||
import { MapSettings } from "./map-widget-context.interface";
|
|
||||||
|
|
||||||
export const zeroCoordinates = { lat: 0, lon: 0 };
|
|
||||||
|
|
||||||
export const EMPTY_SETTING_VALUE: MapSettings = {
|
|
||||||
rotateAngle: 0,
|
|
||||||
center: zeroCoordinates,
|
|
||||||
fullScale: 0,
|
|
||||||
zoomedScale: 0,
|
|
||||||
};
|
|
@ -1,38 +0,0 @@
|
|||||||
import {
|
|
||||||
AttractionGroupIconSizeType,
|
|
||||||
Coordinates,
|
|
||||||
StationOnMap as StationOnMapBase,
|
|
||||||
Track,
|
|
||||||
uuid,
|
|
||||||
Transfer,
|
|
||||||
} from "@mt/common-types";
|
|
||||||
|
|
||||||
export type PointOnTrack = Coordinates & {
|
|
||||||
trackIndex: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type StationOnMap = StationOnMapBase & {
|
|
||||||
pointOnMap: PointOnTrack;
|
|
||||||
transferStationInfos: Transfer[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface AttractionOnMap {
|
|
||||||
id: uuid;
|
|
||||||
pointOnMap: Coordinates;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AttractionGroup {
|
|
||||||
iconSize: AttractionGroupIconSizeType;
|
|
||||||
pointOnMap: Coordinates;
|
|
||||||
touristAttractionsOnMap: AttractionOnMap[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MapData {
|
|
||||||
mapRotateAngle: number;
|
|
||||||
fullMapScale: number;
|
|
||||||
zoomedMapScale: number;
|
|
||||||
centerOfMapPoint: Coordinates;
|
|
||||||
trackPoints: Track;
|
|
||||||
stationsOnMap: StationOnMap[];
|
|
||||||
touristAttractionGroupsOnMap: AttractionGroup[];
|
|
||||||
}
|
|
@ -1,23 +0,0 @@
|
|||||||
import { Coordinates } from '@mt/common-types';
|
|
||||||
import { getDistance } from './get-distance';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This function return deviation of point form the passed straight line
|
|
||||||
* If deviation equals 0 this means the point lay on the line
|
|
||||||
* otherwise don't and we can draw a triangle by this 3 point
|
|
||||||
* @param begin: Point
|
|
||||||
* @param end: Point
|
|
||||||
* @param point: Point
|
|
||||||
* @returns deviation: number
|
|
||||||
*/
|
|
||||||
export function getPointDeviation(
|
|
||||||
begin: Coordinates,
|
|
||||||
end: Coordinates,
|
|
||||||
point: Coordinates
|
|
||||||
): number {
|
|
||||||
const distanceBtw = getDistance(begin, end);
|
|
||||||
const distanceTo = getDistance(begin, point);
|
|
||||||
const distanceFrom = getDistance(point, end);
|
|
||||||
|
|
||||||
return distanceBtw - (distanceFrom + distanceTo);
|
|
||||||
}
|
|
@ -1,28 +0,0 @@
|
|||||||
import { Coordinates } from '@mt/common-types';
|
|
||||||
|
|
||||||
const EARTH_RADIUS = 6372795; // meters
|
|
||||||
|
|
||||||
export function getDistance(a: Coordinates, b: Coordinates): number {
|
|
||||||
const { PI, sin, cos, pow, sqrt, atan2 } = Math;
|
|
||||||
|
|
||||||
const aRad = {
|
|
||||||
lat: (a.lat * PI) / 180,
|
|
||||||
lon: (a.lon * PI) / 180,
|
|
||||||
};
|
|
||||||
|
|
||||||
const bRad = {
|
|
||||||
lat: (b.lat * PI) / 180,
|
|
||||||
lon: (b.lon * PI) / 180,
|
|
||||||
};
|
|
||||||
|
|
||||||
const delta = bRad.lon - aRad.lon;
|
|
||||||
|
|
||||||
// вычисления длины большого круга
|
|
||||||
const y = sqrt(
|
|
||||||
pow(cos(bRad.lat) * sin(delta), 2) +
|
|
||||||
pow(cos(aRad.lat) * sin(bRad.lat) - sin(aRad.lat) * cos(bRad.lat) * cos(delta), 2)
|
|
||||||
);
|
|
||||||
const x = sin(aRad.lat) * sin(bRad.lat) + cos(aRad.lat) * cos(bRad.lat) * cos(delta);
|
|
||||||
|
|
||||||
return +(atan2(y, x) * EARTH_RADIUS).toFixed(2);
|
|
||||||
}
|
|
@ -1,6 +0,0 @@
|
|||||||
import { Coordinates } from '@mt/common-types';
|
|
||||||
import { Point } from 'react-simple-maps';
|
|
||||||
|
|
||||||
export function getMapPoint({ lat, lon }: Coordinates): Point {
|
|
||||||
return [lon, lat];
|
|
||||||
}
|
|
@ -1,4 +0,0 @@
|
|||||||
export * from './get-deviation';
|
|
||||||
export * from './get-distance';
|
|
||||||
export * from './get-map-point';
|
|
||||||
export * from './intersections';
|
|
@ -1,30 +0,0 @@
|
|||||||
interface Rectangle {
|
|
||||||
left: number;
|
|
||||||
top: number;
|
|
||||||
right: number;
|
|
||||||
bottom: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getIntersection(rect1: Rectangle, rect2: Rectangle): Rectangle | null {
|
|
||||||
const left = Math.max(rect1.left, rect2.left);
|
|
||||||
const top = Math.max(rect1.top, rect2.top);
|
|
||||||
const right = Math.min(rect1.right, rect2.right);
|
|
||||||
const bottom = Math.min(rect1.bottom, rect2.bottom);
|
|
||||||
|
|
||||||
if (left < right && top < bottom) {
|
|
||||||
return { left, top, right, bottom };
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getIntersectionArea(rect1: Rectangle, rect2: Rectangle): number {
|
|
||||||
const intersection = getIntersection(rect1, rect2);
|
|
||||||
if (intersection === null) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
const width = intersection.right - intersection.left;
|
|
||||||
const height = intersection.bottom - intersection.top;
|
|
||||||
return width * height;
|
|
||||||
}
|
|
@ -1,7 +0,0 @@
|
|||||||
export const MyComponent = () => {
|
|
||||||
return (
|
|
||||||
<div style={{ width: "100px", height: "100px", backgroundColor: "red" }}>
|
|
||||||
MyComponent
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,67 +0,0 @@
|
|||||||
.root {
|
|
||||||
display: flex;
|
|
||||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0) 100%),
|
|
||||||
rgba(179, 165, 152, 0.4);
|
|
||||||
box-shadow: inset 4px 4px 12px rgba(255, 255, 255, 0.12);
|
|
||||||
border-radius: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.number {
|
|
||||||
width: 96px;
|
|
||||||
height: 96px;
|
|
||||||
background: #fcd500;
|
|
||||||
border-radius: 10px;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
font-size: 92px;
|
|
||||||
font-weight: 900;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content {
|
|
||||||
width: 265px;
|
|
||||||
height: 96px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title {
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.crawlLine {
|
|
||||||
display: inline-block;
|
|
||||||
animation: crawl linear infinite;
|
|
||||||
animation-duration: 10s;
|
|
||||||
animation-timing-function: linear;
|
|
||||||
animation-iteration-count: infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.titleStart,
|
|
||||||
.titleEnd {
|
|
||||||
font-weight: 700;
|
|
||||||
font-size: 24px;
|
|
||||||
line-height: 28px;
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.titleTranslation {
|
|
||||||
margin-top: 4px;
|
|
||||||
font-weight: 400;
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 15px;
|
|
||||||
|
|
||||||
color: #cbcbcb;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes crawl {
|
|
||||||
0% {
|
|
||||||
transform: translateX(100%);
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
transform: translateX(-100%);
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,74 +0,0 @@
|
|||||||
import React, { HTMLAttributes, useContext, useEffect, useRef } from "react";
|
|
||||||
import { LocalizationContext, LocalizedString } from "@mt/i18n";
|
|
||||||
|
|
||||||
import styles from "./RouteInfoWidget.module.css";
|
|
||||||
import cn from "classnames";
|
|
||||||
|
|
||||||
export interface RouteInfoData {
|
|
||||||
routeNumber: string;
|
|
||||||
firstStationName: LocalizedString;
|
|
||||||
lastStationName: LocalizedString;
|
|
||||||
}
|
|
||||||
interface RouteInfoWidgetProps extends HTMLAttributes<HTMLDivElement> {
|
|
||||||
routeInfo?: RouteInfoData;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RouteInfoWidget({
|
|
||||||
routeInfo,
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: RouteInfoWidgetProps) {
|
|
||||||
const contentContainerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const titleRefs = useRef<Array<HTMLSpanElement>>([]);
|
|
||||||
|
|
||||||
const { locale } = useContext(LocalizationContext);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!routeInfo?.firstStationName || !routeInfo?.lastStationName) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
||||||
const container = contentContainerRef.current!;
|
|
||||||
const containerWidth = container.offsetWidth;
|
|
||||||
|
|
||||||
titleRefs.current.forEach((title) => {
|
|
||||||
const titleWidth = title.offsetWidth;
|
|
||||||
const paddingWidth = 8 * 2;
|
|
||||||
|
|
||||||
if (titleWidth + paddingWidth > containerWidth) {
|
|
||||||
title.classList.add(styles.crawlLine);
|
|
||||||
} else {
|
|
||||||
title.classList.remove(styles.crawlLine);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, [routeInfo, titleRefs, contentContainerRef]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cn(styles.root, className)} {...props}>
|
|
||||||
<div className={styles.number}>{routeInfo?.routeNumber || "--"}</div>
|
|
||||||
|
|
||||||
{routeInfo ? (
|
|
||||||
<div className={styles.content} ref={contentContainerRef}>
|
|
||||||
<div className={cn(styles.title, styles.titleStart)}>
|
|
||||||
<span ref={(ref) => (titleRefs.current[0] = ref!)}>
|
|
||||||
{routeInfo.firstStationName.ru}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className={cn(styles.title, styles.titleEnd)}>
|
|
||||||
<span ref={(ref) => (titleRefs.current[1] = ref!)}>
|
|
||||||
{routeInfo.lastStationName.ru}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className={cn(styles.title, styles.titleTranslation)}>
|
|
||||||
<span ref={(ref) => (titleRefs.current[2] = ref!)}>
|
|
||||||
{locale === "zh"
|
|
||||||
? `${routeInfo.firstStationName.zh} – ${routeInfo.lastStationName.zh}`
|
|
||||||
: `${routeInfo.firstStationName.en} – ${routeInfo.lastStationName.en}`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,24 +0,0 @@
|
|||||||
.root {
|
|
||||||
position: relative;
|
|
||||||
touch-action: none;
|
|
||||||
overflow-y: hidden !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scrollbar {
|
|
||||||
--scrollbar-min-height: 10px;
|
|
||||||
--scrollbar-height: var(--scrollbar-min-height);
|
|
||||||
--scrollbar-offset: 0px;
|
|
||||||
--scrollbar-visibility: hidden;
|
|
||||||
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
right: 0;
|
|
||||||
width: 3px;
|
|
||||||
border-radius: 6px;
|
|
||||||
z-index: 1;
|
|
||||||
background-color: #ffffff;
|
|
||||||
min-height: var(--scrollbar-min-height);
|
|
||||||
height: var(--scrollbar-height);
|
|
||||||
visibility: var(--scrollbar-visibility);
|
|
||||||
transform: translateY(var(--scrollbar-offset));
|
|
||||||
}
|
|
@ -1,142 +0,0 @@
|
|||||||
import {
|
|
||||||
HTMLAttributes,
|
|
||||||
PointerEvent,
|
|
||||||
WheelEvent,
|
|
||||||
useEffect,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import cn from "classnames";
|
|
||||||
import styles from "./TouchScrollWrapper.module.css";
|
|
||||||
import { useCssProperty } from "@mt/utils";
|
|
||||||
|
|
||||||
const getNumberPxFormatter = (numberStr: string | null) =>
|
|
||||||
Number(numberStr?.replace(/px$/, ""));
|
|
||||||
const setNumberPxFormatter = (number: number) => `${number}px`;
|
|
||||||
|
|
||||||
const { abs, min } = Math;
|
|
||||||
|
|
||||||
export const TouchScrollWrapper = ({
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: HTMLAttributes<HTMLDivElement>) => {
|
|
||||||
const contentRef = useRef<HTMLDivElement>(null);
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const scrollbarRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const scrollbarHeight = useCssProperty<number>(
|
|
||||||
"--scrollbar-height",
|
|
||||||
scrollbarRef,
|
|
||||||
setNumberPxFormatter,
|
|
||||||
getNumberPxFormatter
|
|
||||||
);
|
|
||||||
const scrollbarVisibility = useCssProperty<string>(
|
|
||||||
"--scrollbar-visibility",
|
|
||||||
scrollbarRef
|
|
||||||
);
|
|
||||||
const scrollbarOffset = useCssProperty<number>(
|
|
||||||
"--scrollbar-offset",
|
|
||||||
scrollbarRef,
|
|
||||||
setNumberPxFormatter
|
|
||||||
);
|
|
||||||
|
|
||||||
const [contentHeight, setContentHeight] = useState<number>(0);
|
|
||||||
const [containerHeight, setContainerHeight] = useState<number>(0);
|
|
||||||
const [startSwipeY, setStartSwipeY] = useState<number>(0);
|
|
||||||
const [startContentOffset, setStartContentOffset] = useState<number>(0);
|
|
||||||
const [pointerId, setPointerId] = useState(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const containerEl = containerRef?.current;
|
|
||||||
const contentEl = contentRef?.current;
|
|
||||||
|
|
||||||
if (!(containerEl && contentEl)) return;
|
|
||||||
|
|
||||||
const observer = new ResizeObserver(() => {
|
|
||||||
setContainerHeight(containerEl.offsetHeight);
|
|
||||||
setContentHeight(containerEl.scrollHeight);
|
|
||||||
});
|
|
||||||
|
|
||||||
observer.observe(containerEl);
|
|
||||||
observer.observe(contentEl);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
observer.disconnect();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (containerHeight >= contentHeight) {
|
|
||||||
scrollbarVisibility.value = "hidden";
|
|
||||||
} else {
|
|
||||||
scrollbarHeight.value =
|
|
||||||
(containerHeight / contentHeight) * containerHeight + 1;
|
|
||||||
scrollbarVisibility.value = "visible";
|
|
||||||
}
|
|
||||||
}, [contentHeight, containerHeight]);
|
|
||||||
|
|
||||||
const handlePointerDown = (e: PointerEvent) => {
|
|
||||||
setStartSwipeY(e.clientY);
|
|
||||||
setStartContentOffset(containerRef?.current?.scrollTop || 0);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTouchScroll = (e: PointerEvent) => {
|
|
||||||
const swipeDistance = startSwipeY - e.clientY;
|
|
||||||
|
|
||||||
if (
|
|
||||||
e.pointerType !== "touch" ||
|
|
||||||
containerHeight >= contentHeight ||
|
|
||||||
(pointerId && pointerId !== e.pointerId) ||
|
|
||||||
abs(swipeDistance) < 2
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setPointerId(e.pointerId);
|
|
||||||
|
|
||||||
containerRef?.current?.scrollTo(0, startContentOffset + swipeDistance);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleScroll = (e: WheelEvent) => {
|
|
||||||
if (containerHeight >= contentHeight) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
containerRef?.current?.scrollBy(0, e.deltaY);
|
|
||||||
};
|
|
||||||
|
|
||||||
const capturePointerUp = (e: PointerEvent) => {
|
|
||||||
if (pointerId) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
setPointerId(0);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const captureScroll = () => {
|
|
||||||
const { scrollTop } = containerRef.current as HTMLDivElement;
|
|
||||||
const barHeight = (scrollbarHeight.value as number) + 1; // plus 1 safety px!;
|
|
||||||
const barOffset = (scrollTop * barHeight) / containerHeight;
|
|
||||||
const maxBarOffset = containerHeight - barHeight;
|
|
||||||
|
|
||||||
scrollbarOffset.value = scrollTop + min(barOffset, maxBarOffset);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(styles.root, className)}
|
|
||||||
ref={containerRef}
|
|
||||||
onPointerDown={handlePointerDown}
|
|
||||||
onPointerMove={handleTouchScroll}
|
|
||||||
onPointerUpCapture={capturePointerUp}
|
|
||||||
onWheel={handleScroll}
|
|
||||||
onScroll={captureScroll}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<div className={styles.scrollbar} ref={scrollbarRef} />
|
|
||||||
|
|
||||||
<div ref={contentRef}>{children}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,24 +0,0 @@
|
|||||||
import { HTMLAttributes, ReactNode } from "react";
|
|
||||||
|
|
||||||
import { Icons } from "../Icons";
|
|
||||||
import { TransportType } from "@mt/common-types";
|
|
||||||
|
|
||||||
const transportStopIcons: Record<TransportType, ReactNode> = {
|
|
||||||
METRO_RED: <Icons.SubwayIcon width="18" height="18" fill="#E52629" />,
|
|
||||||
METRO_BLUE: <Icons.SubwayIcon width="18" height="18" fill="#2D3B8E" />,
|
|
||||||
METRO_GREEN: <Icons.SubwayIcon width="18" height="18" fill="#056939" />,
|
|
||||||
METRO_ORANGE: <Icons.SubwayIcon width="18" height="18" fill="#EB5C2C" />,
|
|
||||||
METRO_PURPLE: <Icons.SubwayIcon width="18" height="18" fill="#64328A" />,
|
|
||||||
TRAM: <Icons.TramIcon width="18" height="18" />,
|
|
||||||
TRAIN: <Icons.TrainIcon width="18" height="18" />,
|
|
||||||
TROLLEY: <Icons.TrolleyIcon width="18" height="18" />,
|
|
||||||
BUS: <Icons.BusIcon width="18" height="18" />,
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface TransportIconProps extends HTMLAttributes<HTMLDivElement> {
|
|
||||||
type: TransportType;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TransportIcon({ type, ...props }: TransportIconProps) {
|
|
||||||
return <div {...props}>{transportStopIcons[type]}</div>;
|
|
||||||
}
|
|
@ -1,16 +0,0 @@
|
|||||||
export const DetHumidity = () => {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
width="64"
|
|
||||||
height="64"
|
|
||||||
viewBox="0 0 64 64"
|
|
||||||
fill="none"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M32 63.68C19.42 63.68 9.19 53.45 9.19 40.87C9.19 28.25 22.63 9.87001 28.41 2.56001C29.28 1.45001 30.59 0.820007 32 0.820007C33.41 0.820007 34.72 1.45001 35.59 2.56001C41.37 9.88001 54.81 28.26 54.81 40.87C54.81 53.44 44.58 63.68 32 63.68ZM32 4.81001C31.9 4.81001 31.7 4.84001 31.55 5.03001C27.24 10.48 13.19 29.18 13.19 40.86C13.19 51.23 21.63 59.67 32 59.67C42.37 59.67 50.81 51.23 50.81 40.86C50.81 29.18 36.76 10.48 32.46 5.03001C32.3 4.84001 32.1 4.81001 32 4.81001Z"
|
|
||||||
fill="white"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,24 +0,0 @@
|
|||||||
export const DetWind = () => {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
width="64"
|
|
||||||
height="64"
|
|
||||||
viewBox="0 0 64 64"
|
|
||||||
fill="none"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M22.03 23.32H4.59998C3.49998 23.32 2.59998 22.42 2.59998 21.32C2.59998 20.22 3.49998 19.32 4.59998 19.32H22.02C26 19.32 29.24 16.08 29.24 12.1C29.24 8.12001 26 4.88 22.02 4.88C18.04 4.88 14.8 8.12001 14.8 12.1C14.8 13.2 13.9 14.1 12.8 14.1C11.7 14.1 10.8 13.2 10.8 12.1C10.8 5.91001 15.84 0.880005 22.02 0.880005C28.2 0.880005 33.24 5.92001 33.24 12.1C33.24 18.28 28.21 23.32 22.03 23.32Z"
|
|
||||||
fill="white"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M50.17 34.3H14.15C13.05 34.3 12.15 33.4 12.15 32.3C12.15 31.2 13.05 30.3 14.15 30.3H50.17C54.15 30.3 57.39 27.06 57.39 23.08C57.39 19.1 54.15 15.86 50.17 15.86C46.19 15.86 42.95 19.1 42.95 23.08C42.95 24.18 42.05 25.08 40.95 25.08C39.85 25.08 38.95 24.18 38.95 23.08C38.95 16.89 43.99 11.86 50.17 11.86C56.36 11.86 61.39 16.9 61.39 23.08C61.39 29.26 56.36 34.3 50.17 34.3Z"
|
|
||||||
fill="white"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M40.63 63.13C34.44 63.13 29.41 58.09 29.41 51.91C29.41 50.81 30.31 49.91 31.41 49.91C32.51 49.91 33.41 50.81 33.41 51.91C33.41 55.89 36.65 59.13 40.63 59.13C44.61 59.13 47.85 55.89 47.85 51.91C47.85 47.93 44.61 44.69 40.63 44.69H4.59998C3.49998 44.69 2.59998 43.79 2.59998 42.69C2.59998 41.59 3.49998 40.69 4.59998 40.69H40.62C46.81 40.69 51.84 45.73 51.84 51.91C51.84 58.09 46.82 63.13 40.63 63.13Z"
|
|
||||||
fill="white"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,3 +0,0 @@
|
|||||||
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M47.15 14.23C46.84 14.23 46.53 14.24 46.23 14.26C44.81 14.34 43.42 13.73 42.63 12.54C39.6 7.98 34.51 5 28.75 5C21.58 5 15.45 9.63 13.02 16.15C12.62 17.24 11.72 18.08 10.61 18.44C4.47 20.42 0 26.35 0 33.36C0 42 6.78 49 15.15 49H47.15C56.46 49 64 41.22 64 31.61C64 22.01 56.46 14.23 47.15 14.23Z" fill="white"/>
|
|
||||||
</svg>
|
|
Before Width: | Height: | Size: 422 B |
@ -1,4 +0,0 @@
|
|||||||
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M49.0191 29.96C57.2891 29.96 63.9991 23.25 63.9991 14.98C63.9991 6.71 57.2891 0 49.0191 0C40.7491 0 34.0391 6.71 34.0391 14.98C34.0291 23.26 40.7391 29.96 49.0191 29.96Z" fill="#FCD500"/>
|
|
||||||
<path d="M43.4668 17.3834C43.1948 17.3834 42.9128 17.3933 42.6408 17.4033C41.2708 17.4728 39.9713 16.8073 39.1957 15.6948C36.3852 11.6422 31.7514 9 26.5032 9C19.9655 9 14.3748 13.1122 12.078 18.933C11.6348 20.0554 10.7181 20.8997 9.56975 21.2871C4.00922 23.1545 0 28.4984 0 34.7859C0 42.633 6.25559 49 13.9718 49H43.4668C52.0393 49 59 41.9277 59 33.1967C59 24.4656 52.0493 17.3834 43.4668 17.3834Z" fill="white"/>
|
|
||||||
</svg>
|
|
Before Width: | Height: | Size: 714 B |
@ -1,7 +0,0 @@
|
|||||||
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M47.15 11.22C46.84 11.22 46.53 11.23 46.23 11.25C44.81 11.33 43.42 10.72 42.63 9.53C39.59 4.98 34.51 2 28.75 2C21.58 2 15.45 6.63 13.02 13.15C12.62 14.24 11.72 15.08 10.61 15.44C4.47 17.41 0 23.35 0 30.35C0 38.99 6.78 45.99 15.15 45.99H47.15C56.45 45.99 64 38.21 64 28.6C64 19 56.46 11.22 47.15 11.22Z" fill="white"/>
|
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M17.6908 48.5015C18.4079 48.9162 18.6532 49.8337 18.2385 50.5509L14.4685 57.0709C14.0538 57.7881 13.1362 58.0333 12.4191 57.6186C11.7019 57.2039 11.4567 56.2864 11.8714 55.5692L15.6414 49.0492C16.0561 48.332 16.9736 48.0868 17.6908 48.5015Z" fill="#00B1FF"/>
|
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M25.8607 53.2215C26.5779 53.6362 26.8231 54.5537 26.4084 55.2709L22.6384 61.7909C22.2237 62.508 21.3062 62.7533 20.589 62.3386C19.8718 61.9239 19.6266 61.0063 20.0413 60.2892L23.8113 53.7692C24.226 53.052 25.1435 52.8068 25.8607 53.2215Z" fill="#00B1FF"/>
|
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M39.1988 48.9706C39.9165 49.3845 40.1627 50.3017 39.7489 51.0194L35.9889 57.5394C35.575 58.257 34.6578 58.5033 33.9401 58.0894C33.2225 57.6756 32.9762 56.7583 33.39 56.0407L37.1501 49.5207C37.5639 48.803 38.4812 48.5568 39.1988 48.9706Z" fill="#00B1FF"/>
|
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M47.3687 53.6906C48.0864 54.1044 48.3326 55.0217 47.9188 55.7394L44.1588 62.2593C43.7449 62.977 42.8277 63.2233 42.11 62.8094C41.3924 62.3955 41.1461 61.4783 41.56 60.7606L45.32 54.2406C45.7338 53.523 46.6511 53.2767 47.3687 53.6906Z" fill="#00B1FF"/>
|
|
||||||
</svg>
|
|
Before Width: | Height: | Size: 1.6 KiB |
@ -1,11 +0,0 @@
|
|||||||
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M31.95 0.190002C33.0545 0.190002 33.95 1.08543 33.95 2.19V61.87C33.95 62.9746 33.0545 63.87 31.95 63.87C30.8454 63.87 29.95 62.9746 29.95 61.87V2.19C29.95 1.08543 30.8454 0.190002 31.95 0.190002Z" fill="white"/>
|
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M20.4458 4.92578C21.2268 4.14473 22.4932 4.14473 23.2742 4.92578L32.0107 13.6623L40.6265 5.05508C41.4079 4.27442 42.6742 4.27505 43.4549 5.05649C44.2356 5.83793 44.2349 7.10426 43.4535 7.88491L32.0093 19.3177L20.4458 7.75421C19.6647 6.97316 19.6647 5.70683 20.4458 4.92578Z" fill="white"/>
|
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M31.89 45.0416L43.4542 56.6058C44.2352 57.3868 44.2352 58.6532 43.4542 59.4342C42.6732 60.2153 41.4068 60.2153 40.6258 59.4342L31.89 50.6984L23.2742 59.3142C22.4932 60.0953 21.2268 60.0953 20.4458 59.3142C19.6647 58.5332 19.6647 57.2668 20.4458 56.4858L31.89 45.0416Z" fill="white"/>
|
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.36797 16.1101C4.92021 15.1535 6.14337 14.8257 7.09998 15.3779L58.79 45.2179C59.7466 45.7701 60.0744 46.9933 59.5222 47.9499C58.9699 48.9065 57.7468 49.2343 56.7901 48.6821L5.10015 18.8421C4.14354 18.2899 3.81573 17.0667 4.36797 16.1101Z" fill="white"/>
|
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M14.3312 8.57847C15.398 8.29193 16.495 8.92441 16.7816 9.99117L20.981 25.6247L5.17684 29.8521C4.10978 30.1375 3.01339 29.5039 2.72797 28.4368C2.44255 27.3697 3.07619 26.2733 4.14324 25.9879L16.0791 22.7953L12.9185 11.0288C12.632 9.96208 13.2645 8.86502 14.3312 8.57847Z" fill="white"/>
|
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M49.6571 8.51802C50.7242 8.80363 51.3576 9.90015 51.072 10.9672L47.8787 22.8969L59.6488 26.0585C60.7156 26.345 61.3481 27.4421 61.0615 28.5088C60.775 29.5756 59.6779 30.2081 58.6112 29.9215L42.9813 25.7232L47.208 9.93286C47.4936 8.86585 48.5901 8.23241 49.6571 8.51802Z" fill="white"/>
|
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M2.56812 35.7025C2.85394 34.6355 3.95058 34.0023 5.01753 34.2881L20.65 38.4758L16.4117 54.2781C16.1256 55.345 15.0288 55.9779 13.9619 55.6917C12.895 55.4056 12.2621 54.3088 12.5483 53.2419L15.75 41.3042L3.98249 38.1519C2.91554 37.8661 2.28231 36.7694 2.56812 35.7025Z" fill="white"/>
|
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M61.4318 35.7619C61.7179 36.8288 61.085 37.9256 60.0181 38.2117L48.0793 41.4138L51.2319 53.1825C51.5177 54.2494 50.8845 55.3461 49.8176 55.6319C48.7506 55.9177 47.654 55.2845 47.3681 54.2175L43.1808 38.5862L58.9819 34.3483C60.0488 34.0621 61.1456 34.695 61.4318 35.7619Z" fill="white"/>
|
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M59.5222 16.1101C60.0744 17.0667 59.7466 18.2899 58.79 18.8421L7.09998 48.6821C6.14337 49.2343 4.92021 48.9065 4.36797 47.9499C3.81573 46.9933 4.14354 45.7701 5.10015 45.2179L56.7901 15.3779C57.7468 14.8257 58.9699 15.1535 59.5222 16.1101Z" fill="white"/>
|
|
||||||
</svg>
|
|
Before Width: | Height: | Size: 2.9 KiB |
@ -1,11 +0,0 @@
|
|||||||
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M47.15 10.22C46.84 10.22 46.53 10.23 46.23 10.25C44.81 10.33 43.42 9.72 42.63 8.53C39.59 3.98 34.51 1 28.75 1C21.58 1 15.45 5.63 13.02 12.15C12.62 13.24 11.72 14.08 10.61 14.44C4.47 16.42 0 22.35 0 29.36C0 38 6.78 45 15.15 45H47.15C56.45 45 64 37.22 64 27.61C64 18.01 56.46 10.22 47.15 10.22Z" fill="white"/>
|
|
||||||
<path d="M34.6995 58.07C35.5334 58.07 36.2095 57.394 36.2095 56.56C36.2095 55.7261 35.5334 55.05 34.6995 55.05C33.8655 55.05 33.1895 55.7261 33.1895 56.56C33.1895 57.394 33.8655 58.07 34.6995 58.07Z" fill="white"/>
|
|
||||||
<path d="M38.4397 51.5901C39.2736 51.5901 39.9497 50.914 39.9497 50.0801C39.9497 49.2461 39.2736 48.5701 38.4397 48.5701C37.6057 48.5701 36.9297 49.2461 36.9297 50.0801C36.9297 50.914 37.6057 51.5901 38.4397 51.5901Z" fill="white"/>
|
|
||||||
<path d="M42.8694 62.82C43.7033 62.82 44.3794 62.144 44.3794 61.31C44.3794 60.4761 43.7033 59.8 42.8694 59.8C42.0354 59.8 41.3594 60.4761 41.3594 61.31C41.3594 62.144 42.0354 62.82 42.8694 62.82Z" fill="white"/>
|
|
||||||
<path d="M46.6096 56.3401C47.4436 56.3401 48.1196 55.664 48.1196 54.8301C48.1196 53.9961 47.4436 53.3201 46.6096 53.3201C45.7757 53.3201 45.0996 53.9961 45.0996 54.8301C45.0996 55.664 45.7757 56.3401 46.6096 56.3401Z" fill="white"/>
|
|
||||||
<path d="M13.1799 58.07C14.0139 58.07 14.6899 57.394 14.6899 56.56C14.6899 55.7261 14.0139 55.05 13.1799 55.05C12.346 55.05 11.6699 55.7261 11.6699 56.56C11.6699 57.394 12.346 58.07 13.1799 58.07Z" fill="white"/>
|
|
||||||
<path d="M16.9202 51.5901C17.7541 51.5901 18.4302 50.914 18.4302 50.0801C18.4302 49.2461 17.7541 48.5701 16.9202 48.5701C16.0862 48.5701 15.4102 49.2461 15.4102 50.0801C15.4102 50.914 16.0862 51.5901 16.9202 51.5901Z" fill="white"/>
|
|
||||||
<path d="M21.3498 62.82C22.1838 62.82 22.8598 62.144 22.8598 61.31C22.8598 60.4761 22.1838 59.8 21.3498 59.8C20.5159 59.8 19.8398 60.4761 19.8398 61.31C19.8398 62.144 20.5159 62.82 21.3498 62.82Z" fill="white"/>
|
|
||||||
<path d="M25.0901 56.3401C25.924 56.3401 26.6001 55.664 26.6001 54.8301C26.6001 53.9961 25.924 53.3201 25.0901 53.3201C24.2561 53.3201 23.5801 53.9961 23.5801 54.8301C23.5801 55.664 24.2561 56.3401 25.0901 56.3401Z" fill="white"/>
|
|
||||||
</svg>
|
|
Before Width: | Height: | Size: 2.2 KiB |
@ -1,18 +0,0 @@
|
|||||||
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<g clip-path="url(#clip0_383_344)">
|
|
||||||
<path d="M19.3993 21.64C18.8293 21.64 18.2593 21.42 17.8193 20.99L9.3693 12.53C8.4993 11.66 8.4993 10.25 9.3693 9.38004C10.2393 8.51004 11.6493 8.51004 12.5193 9.38004L20.9693 17.83C21.8393 18.7 21.8393 20.11 20.9693 20.98C20.5393 21.42 19.9693 21.64 19.3993 21.64Z" fill="#FCD500"/>
|
|
||||||
<path d="M61.7701 34.24H49.8101C48.5801 34.24 47.5801 33.24 47.5801 32.01C47.5801 30.78 48.5801 29.78 49.8101 29.78H61.7701C63.0001 29.78 64.0001 30.78 64.0001 32.01C64.0001 33.24 63.0001 34.24 61.7701 34.24Z" fill="#FCD500"/>
|
|
||||||
<path d="M44.5997 21.64C44.0297 21.64 43.4597 21.42 43.0197 20.99C42.1497 20.12 42.1497 18.71 43.0197 17.84L51.4697 9.39005C52.3397 8.52005 53.7497 8.52005 54.6197 9.39005C55.4897 10.26 55.4897 11.67 54.6197 12.54L46.1697 20.99C45.7397 21.42 45.1697 21.64 44.5997 21.64Z" fill="#FCD500"/>
|
|
||||||
<path d="M31.9995 16.42C30.7695 16.42 29.7695 15.42 29.7695 14.19V2.23C29.7695 1 30.7695 0 31.9995 0C33.2295 0 34.2295 1 34.2295 2.23V14.19C34.2295 15.42 33.2295 16.42 31.9995 16.42Z" fill="#FCD500"/>
|
|
||||||
<path d="M14.19 34.24H2.24C1 34.24 0 33.24 0 32.01C0 30.78 1 29.78 2.23 29.78H14.18C15.41 29.78 16.41 30.78 16.41 32.01C16.41 33.24 15.42 34.24 14.19 34.24Z" fill="#FCD500"/>
|
|
||||||
<path d="M10.9493 55.29C10.3793 55.29 9.8093 55.0699 9.3693 54.6399C8.4993 53.7699 8.4993 52.36 9.3693 51.49L17.8193 43.04C18.6893 42.17 20.0993 42.17 20.9693 43.04C21.8393 43.91 21.8393 45.3199 20.9693 46.1899L12.5193 54.6399C12.0893 55.0699 11.5193 55.29 10.9493 55.29Z" fill="#FCD500"/>
|
|
||||||
<path d="M31.9995 64.0098C30.7695 64.0098 29.7695 63.0098 29.7695 61.7798V49.8198C29.7695 48.5898 30.7695 47.5898 31.9995 47.5898C33.2295 47.5898 34.2295 48.5898 34.2295 49.8198V61.7798C34.2295 63.0098 33.2295 64.0098 31.9995 64.0098Z" fill="#FCD500"/>
|
|
||||||
<path d="M53.0497 55.29C52.4797 55.29 51.9097 55.0699 51.4697 54.6399L43.0197 46.1899C42.1497 45.3199 42.1497 43.91 43.0197 43.04C43.8897 42.17 45.2997 42.17 46.1697 43.04L54.6197 51.49C55.4897 52.36 55.4897 53.7699 54.6197 54.6399C54.1897 55.0699 53.6197 55.29 53.0497 55.29Z" fill="#FCD500"/>
|
|
||||||
<path d="M32 43.9299C38.6252 43.9299 44 38.5551 44 31.9299C44 25.3047 38.6252 19.9299 32 19.9299C25.3748 19.9299 20 25.3047 20 31.9299C20 38.5551 25.3748 43.9299 32 43.9299Z" fill="#FCD500"/>
|
|
||||||
</g>
|
|
||||||
<defs>
|
|
||||||
<clipPath id="clip0_383_344">
|
|
||||||
<rect width="64" height="64" fill="white"/>
|
|
||||||
</clipPath>
|
|
||||||
</defs>
|
|
||||||
</svg>
|
|
Before Width: | Height: | Size: 2.4 KiB |
@ -1,4 +0,0 @@
|
|||||||
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M47.15 12.22C46.84 12.22 46.53 12.23 46.23 12.25C44.81 12.33 43.42 11.72 42.63 10.53C39.59 5.98 34.51 3 28.75 3C21.58 3 15.45 7.63 13.02 14.15C12.62 15.24 11.72 16.08 10.61 16.44C4.47 18.42 0 24.35 0 31.35C0 39.99 6.78 46.99 15.15 46.99H47.15C56.45 46.99 64 39.21 64 29.6C64 20 56.46 12.22 47.15 12.22Z" fill="white"/>
|
|
||||||
<path d="M26.0996 47.0101L47.6396 22.1001L44.5296 36.8901H56.9796L33.3696 61.8001L37.0596 47.0101H26.0996Z" fill="#FCD500"/>
|
|
||||||
</svg>
|
|
Before Width: | Height: | Size: 556 B |
@ -1,3 +0,0 @@
|
|||||||
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M32 63.68C19.42 63.68 9.19 53.45 9.19 40.87C9.19 28.25 22.63 9.87001 28.41 2.56001C29.28 1.45001 30.59 0.820007 32 0.820007C33.41 0.820007 34.72 1.45001 35.59 2.56001C41.37 9.88001 54.81 28.26 54.81 40.87C54.81 53.44 44.58 63.68 32 63.68ZM32 4.81001C31.9 4.81001 31.7 4.84001 31.55 5.03001C27.24 10.48 13.19 29.18 13.19 40.86C13.19 51.23 21.63 59.67 32 59.67C42.37 59.67 50.81 51.23 50.81 40.86C50.81 29.18 36.76 10.48 32.46 5.03001C32.3 4.84001 32.1 4.81001 32 4.81001Z" fill="white"/>
|
|
||||||
</svg>
|
|
Before Width: | Height: | Size: 599 B |
@ -1,5 +0,0 @@
|
|||||||
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M22.03 23.32H4.59998C3.49998 23.32 2.59998 22.42 2.59998 21.32C2.59998 20.22 3.49998 19.32 4.59998 19.32H22.02C26 19.32 29.24 16.08 29.24 12.1C29.24 8.12001 26 4.88 22.02 4.88C18.04 4.88 14.8 8.12001 14.8 12.1C14.8 13.2 13.9 14.1 12.8 14.1C11.7 14.1 10.8 13.2 10.8 12.1C10.8 5.91001 15.84 0.880005 22.02 0.880005C28.2 0.880005 33.24 5.92001 33.24 12.1C33.24 18.28 28.21 23.32 22.03 23.32Z" fill="white"/>
|
|
||||||
<path d="M50.17 34.3H14.15C13.05 34.3 12.15 33.4 12.15 32.3C12.15 31.2 13.05 30.3 14.15 30.3H50.17C54.15 30.3 57.39 27.06 57.39 23.08C57.39 19.1 54.15 15.86 50.17 15.86C46.19 15.86 42.95 19.1 42.95 23.08C42.95 24.18 42.05 25.08 40.95 25.08C39.85 25.08 38.95 24.18 38.95 23.08C38.95 16.89 43.99 11.86 50.17 11.86C56.36 11.86 61.39 16.9 61.39 23.08C61.39 29.26 56.36 34.3 50.17 34.3Z" fill="white"/>
|
|
||||||
<path d="M40.63 63.13C34.44 63.13 29.41 58.09 29.41 51.91C29.41 50.81 30.31 49.91 31.41 49.91C32.51 49.91 33.41 50.81 33.41 51.91C33.41 55.89 36.65 59.13 40.63 59.13C44.61 59.13 47.85 55.89 47.85 51.91C47.85 47.93 44.61 44.69 40.63 44.69H4.59998C3.49998 44.69 2.59998 43.79 2.59998 42.69C2.59998 41.59 3.49998 40.69 4.59998 40.69H40.62C46.81 40.69 51.84 45.73 51.84 51.91C51.84 58.09 46.82 63.13 40.63 63.13Z" fill="white"/>
|
|
||||||
</svg>
|
|
Before Width: | Height: | Size: 1.3 KiB |
@ -1,3 +0,0 @@
|
|||||||
export * from './weather-widget';
|
|
||||||
export * from './weather.interface';
|
|
||||||
export * from './weather.constant';
|
|
@ -1,44 +0,0 @@
|
|||||||
import { createElement } from "react";
|
|
||||||
import IconCloudy from "./icons/cond_cloudy.svg";
|
|
||||||
import IconRainy from "./icons/cond_rainy.svg";
|
|
||||||
import IconPartlyCloudy from "./icons/cond_partlycloudy.svg";
|
|
||||||
import IconSnow from "./icons/cond_snow.svg";
|
|
||||||
import IconSnowy from "./icons/cond_snowy.svg";
|
|
||||||
import IconSunny from "./icons/cond_sunny.svg";
|
|
||||||
import IconThunder from "./icons/cond_thunder.svg";
|
|
||||||
|
|
||||||
interface WeatherWidgetIconProps {
|
|
||||||
icon: string | null;
|
|
||||||
size: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Icons = {
|
|
||||||
CLOUDY: IconCloudy,
|
|
||||||
RAINY: IconRainy,
|
|
||||||
PARTLYCLOUDY: IconPartlyCloudy,
|
|
||||||
SNOW: IconSnow,
|
|
||||||
SNOWY: IconSnowy,
|
|
||||||
SUNNY: IconSunny,
|
|
||||||
THUNDER: IconThunder,
|
|
||||||
};
|
|
||||||
|
|
||||||
export function WeatherWidgetIcon({ icon, size = 16 }: WeatherWidgetIconProps) {
|
|
||||||
const svg = Icons[icon as keyof typeof Icons] || null;
|
|
||||||
if (!svg || !icon)
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: `${size}px`,
|
|
||||||
height: `${size}px`,
|
|
||||||
textAlign: "center",
|
|
||||||
margin: "0 auto",
|
|
||||||
fontSize: `${size}px`,
|
|
||||||
lineHeight: 1,
|
|
||||||
overflow: "hidden",
|
|
||||||
}}
|
|
||||||
children="--"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
return createElement(svg);
|
|
||||||
}
|
|
@ -1,77 +0,0 @@
|
|||||||
import { WeatherWidgetIcon } from "./weather-widget-icon";
|
|
||||||
|
|
||||||
import IconHumidity from "./icons/det_humidity.svg";
|
|
||||||
import IconWind from "./icons/det_wind.svg";
|
|
||||||
import { WeatherDayRow, WeatherWidgetData } from "./weather.interface";
|
|
||||||
|
|
||||||
const Weekdays = ["Вс", "Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"];
|
|
||||||
|
|
||||||
const WRow = ({ temperature, weekday, condition }: WeatherDayRow) => (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
marginBottom: "8px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 16,
|
|
||||||
height: 16,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<WeatherWidgetIcon icon={condition} size={16} />
|
|
||||||
</div>
|
|
||||||
<div style={{ marginLeft: 8, minWidth: 22 }} children={Weekdays[weekday]} />
|
|
||||||
<div style={{ marginLeft: 8 }} children={`${temperature ?? "--"}°`} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
export function WeatherWidgetRight({
|
|
||||||
forecasts,
|
|
||||||
weatherInfo,
|
|
||||||
}: WeatherWidgetData) {
|
|
||||||
const wd = new Date().getDay();
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
textAlign: "center",
|
|
||||||
width: "50%",
|
|
||||||
fontSize: 18,
|
|
||||||
lineHeight: "21px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
borderBottom: "1px solid #999",
|
|
||||||
marginBottom: "8px",
|
|
||||||
marginTop: "8px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{[...forecasts].slice(0, 3).map((d, idx) => (
|
|
||||||
<WRow key={idx} {...d?.weatherInfo} weekday={(wd + idx + 1) % 7} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
marginBottom: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<IconHumidity />
|
|
||||||
<b children={weatherInfo?.humidity ?? "--"} />%
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<IconWind />
|
|
||||||
<b children={weatherInfo?.windSpeed ?? "--"} />
|
|
||||||
м/с
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,54 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
const add0 = (val: number) => `${val < 10 ? '0' : ''}${val}`;
|
|
||||||
const weekdays = [
|
|
||||||
'воскресенье',
|
|
||||||
'понедельник',
|
|
||||||
'вторник',
|
|
||||||
'среда',
|
|
||||||
'четверг',
|
|
||||||
'пятница',
|
|
||||||
'суббота',
|
|
||||||
'воскресенье',
|
|
||||||
];
|
|
||||||
|
|
||||||
export function WeatherWidgetTime() {
|
|
||||||
const [now, setNow] = useState(new Date());
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const interval = setInterval(() => setNow(new Date()), 1000);
|
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
textAlign: 'center',
|
|
||||||
borderBottom: '1px solid #999',
|
|
||||||
paddingBottom: '13px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontWeight: 600,
|
|
||||||
fontSize: '48px',
|
|
||||||
lineHeight: '56px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{now.getHours()}
|
|
||||||
<span children={':'} style={{ opacity: now.getSeconds() % 4 !== 3 ? 1 : 0 }} />
|
|
||||||
{add0(now.getMinutes())}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontWeight: 400,
|
|
||||||
fontSize: '14px',
|
|
||||||
lineHeight: '1',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{now.getDate()}.{add0(now.getMonth() + 1)}, {weekdays[now.getDay()]}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,48 +0,0 @@
|
|||||||
import { WeatherWidgetIcon } from './weather-widget-icon';
|
|
||||||
import { WeatherDayProps } from './weather.interface';
|
|
||||||
|
|
||||||
const WeatherTypes = {
|
|
||||||
RAINY: 'дождь',
|
|
||||||
CLOUDY: 'облачно',
|
|
||||||
PARTLYCLOUDY: 'переменная облачность',
|
|
||||||
SNOW: 'снег',
|
|
||||||
SNOWY: 'идет снег',
|
|
||||||
SUNNY: 'солнце',
|
|
||||||
THUNDER: '',
|
|
||||||
UNKNOWN: 'неизвестно',
|
|
||||||
};
|
|
||||||
|
|
||||||
export function WeatherWidgetToday(props: WeatherDayProps) {
|
|
||||||
const { temperature, condition } = props;
|
|
||||||
|
|
||||||
const wType = WeatherTypes[condition as keyof typeof WeatherTypes] ?? WeatherTypes.UNKNOWN;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
textAlign: 'center',
|
|
||||||
width: '50%',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<WeatherWidgetIcon icon={condition} size={72} />
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: 52,
|
|
||||||
lineHeight: '61px',
|
|
||||||
fontWeight: '600',
|
|
||||||
letterSpacing: '-.075',
|
|
||||||
}}
|
|
||||||
children={`${temperature ?? '--'}°`}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: 16,
|
|
||||||
lineHeight: '18.75px',
|
|
||||||
}}
|
|
||||||
children={wType}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,46 +0,0 @@
|
|||||||
import styled from '@emotion/styled';
|
|
||||||
import { HTMLAttributes } from 'react';
|
|
||||||
import { WeatherWidgetRight } from './weather-widget-right';
|
|
||||||
import { WeatherWidgetTime } from './weather-widget-time';
|
|
||||||
import { WeatherWidgetToday } from './weather-widget-today';
|
|
||||||
import { WeatherWidgetData } from './weather.interface';
|
|
||||||
import { WEATHER_DEFAULTS } from './weather.constant';
|
|
||||||
|
|
||||||
const StyledWeatherWidget = styled.div`
|
|
||||||
width: 225px;
|
|
||||||
height: 260px;
|
|
||||||
padding: 8px;
|
|
||||||
|
|
||||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0) 100%),
|
|
||||||
rgba(179, 165, 152, 0.4);
|
|
||||||
box-shadow: inset 4px 4px 12px rgba(255, 255, 255, 0.12);
|
|
||||||
backdrop-filter: blur(6px);
|
|
||||||
color: #fff;
|
|
||||||
font-weight: 400;
|
|
||||||
|
|
||||||
border-radius: 10px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
interface Props extends HTMLAttributes<HTMLDivElement> {
|
|
||||||
weatherData?: WeatherWidgetData;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function WeatherWidget({ weatherData = WEATHER_DEFAULTS, ...props }: Props) {
|
|
||||||
const { weatherInfo, forecasts } = weatherData;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<StyledWeatherWidget {...props}>
|
|
||||||
<WeatherWidgetTime />
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
marginTop: '8px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<WeatherWidgetToday {...weatherInfo} />
|
|
||||||
<WeatherWidgetRight weatherInfo={weatherInfo} forecasts={forecasts} />
|
|
||||||
</div>
|
|
||||||
</StyledWeatherWidget>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,28 +0,0 @@
|
|||||||
export const WEATHER_DEFAULTS = {
|
|
||||||
weatherInfo: {
|
|
||||||
condition: null,
|
|
||||||
temperature: null,
|
|
||||||
humidity: null,
|
|
||||||
windSpeed: null,
|
|
||||||
},
|
|
||||||
forecasts: [
|
|
||||||
{
|
|
||||||
weatherInfo: {
|
|
||||||
condition: null,
|
|
||||||
temperature: null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
weatherInfo: {
|
|
||||||
condition: null,
|
|
||||||
temperature: null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
weatherInfo: {
|
|
||||||
condition: null,
|
|
||||||
temperature: null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
@ -1,29 +0,0 @@
|
|||||||
export type WeatherTypes =
|
|
||||||
| 'CLOUDY'
|
|
||||||
| 'PARTLYCLOUDY'
|
|
||||||
| 'RAINY'
|
|
||||||
| 'SNOW'
|
|
||||||
| 'SNOWY'
|
|
||||||
| 'SUNNY'
|
|
||||||
| 'THUNDER';
|
|
||||||
|
|
||||||
export interface WeatherDayShortProps {
|
|
||||||
condition: WeatherTypes | null;
|
|
||||||
temperature: number | null;
|
|
||||||
}
|
|
||||||
export type WeatherDayProps = WeatherDayShortProps & {
|
|
||||||
humidity: number | null;
|
|
||||||
windSpeed: number | null;
|
|
||||||
};
|
|
||||||
export interface WeatherForecastsProps {
|
|
||||||
weatherInfo: WeatherDayShortProps;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WeatherWidgetData {
|
|
||||||
forecasts: WeatherForecastsProps[];
|
|
||||||
weatherInfo: WeatherDayProps;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type WeatherDayRow = WeatherDayShortProps & {
|
|
||||||
weekday: number;
|
|
||||||
};
|
|
@ -1,202 +0,0 @@
|
|||||||
|
|
||||||
Apache License
|
|
||||||
Version 2.0, January 2004
|
|
||||||
http://www.apache.org/licenses/
|
|
||||||
|
|
||||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
||||||
|
|
||||||
1. Definitions.
|
|
||||||
|
|
||||||
"License" shall mean the terms and conditions for use, reproduction,
|
|
||||||
and distribution as defined by Sections 1 through 9 of this document.
|
|
||||||
|
|
||||||
"Licensor" shall mean the copyright owner or entity authorized by
|
|
||||||
the copyright owner that is granting the License.
|
|
||||||
|
|
||||||
"Legal Entity" shall mean the union of the acting entity and all
|
|
||||||
other entities that control, are controlled by, or are under common
|
|
||||||
control with that entity. For the purposes of this definition,
|
|
||||||
"control" means (i) the power, direct or indirect, to cause the
|
|
||||||
direction or management of such entity, whether by contract or
|
|
||||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
||||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
||||||
|
|
||||||
"You" (or "Your") shall mean an individual or Legal Entity
|
|
||||||
exercising permissions granted by this License.
|
|
||||||
|
|
||||||
"Source" form shall mean the preferred form for making modifications,
|
|
||||||
including but not limited to software source code, documentation
|
|
||||||
source, and configuration files.
|
|
||||||
|
|
||||||
"Object" form shall mean any form resulting from mechanical
|
|
||||||
transformation or translation of a Source form, including but
|
|
||||||
not limited to compiled object code, generated documentation,
|
|
||||||
and conversions to other media types.
|
|
||||||
|
|
||||||
"Work" shall mean the work of authorship, whether in Source or
|
|
||||||
Object form, made available under the License, as indicated by a
|
|
||||||
copyright notice that is included in or attached to the work
|
|
||||||
(an example is provided in the Appendix below).
|
|
||||||
|
|
||||||
"Derivative Works" shall mean any work, whether in Source or Object
|
|
||||||
form, that is based on (or derived from) the Work and for which the
|
|
||||||
editorial revisions, annotations, elaborations, or other modifications
|
|
||||||
represent, as a whole, an original work of authorship. For the purposes
|
|
||||||
of this License, Derivative Works shall not include works that remain
|
|
||||||
separable from, or merely link (or bind by name) to the interfaces of,
|
|
||||||
the Work and Derivative Works thereof.
|
|
||||||
|
|
||||||
"Contribution" shall mean any work of authorship, including
|
|
||||||
the original version of the Work and any modifications or additions
|
|
||||||
to that Work or Derivative Works thereof, that is intentionally
|
|
||||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
||||||
or by an individual or Legal Entity authorized to submit on behalf of
|
|
||||||
the copyright owner. For the purposes of this definition, "submitted"
|
|
||||||
means any form of electronic, verbal, or written communication sent
|
|
||||||
to the Licensor or its representatives, including but not limited to
|
|
||||||
communication on electronic mailing lists, source code control systems,
|
|
||||||
and issue tracking systems that are managed by, or on behalf of, the
|
|
||||||
Licensor for the purpose of discussing and improving the Work, but
|
|
||||||
excluding communication that is conspicuously marked or otherwise
|
|
||||||
designated in writing by the copyright owner as "Not a Contribution."
|
|
||||||
|
|
||||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
||||||
on behalf of whom a Contribution has been received by Licensor and
|
|
||||||
subsequently incorporated within the Work.
|
|
||||||
|
|
||||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
||||||
this License, each Contributor hereby grants to You a perpetual,
|
|
||||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
||||||
copyright license to reproduce, prepare Derivative Works of,
|
|
||||||
publicly display, publicly perform, sublicense, and distribute the
|
|
||||||
Work and such Derivative Works in Source or Object form.
|
|
||||||
|
|
||||||
3. Grant of Patent License. Subject to the terms and conditions of
|
|
||||||
this License, each Contributor hereby grants to You a perpetual,
|
|
||||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
||||||
(except as stated in this section) patent license to make, have made,
|
|
||||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
||||||
where such license applies only to those patent claims licensable
|
|
||||||
by such Contributor that are necessarily infringed by their
|
|
||||||
Contribution(s) alone or by combination of their Contribution(s)
|
|
||||||
with the Work to which such Contribution(s) was submitted. If You
|
|
||||||
institute patent litigation against any entity (including a
|
|
||||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
||||||
or a Contribution incorporated within the Work constitutes direct
|
|
||||||
or contributory patent infringement, then any patent licenses
|
|
||||||
granted to You under this License for that Work shall terminate
|
|
||||||
as of the date such litigation is filed.
|
|
||||||
|
|
||||||
4. Redistribution. You may reproduce and distribute copies of the
|
|
||||||
Work or Derivative Works thereof in any medium, with or without
|
|
||||||
modifications, and in Source or Object form, provided that You
|
|
||||||
meet the following conditions:
|
|
||||||
|
|
||||||
(a) You must give any other recipients of the Work or
|
|
||||||
Derivative Works a copy of this License; and
|
|
||||||
|
|
||||||
(b) You must cause any modified files to carry prominent notices
|
|
||||||
stating that You changed the files; and
|
|
||||||
|
|
||||||
(c) You must retain, in the Source form of any Derivative Works
|
|
||||||
that You distribute, all copyright, patent, trademark, and
|
|
||||||
attribution notices from the Source form of the Work,
|
|
||||||
excluding those notices that do not pertain to any part of
|
|
||||||
the Derivative Works; and
|
|
||||||
|
|
||||||
(d) If the Work includes a "NOTICE" text file as part of its
|
|
||||||
distribution, then any Derivative Works that You distribute must
|
|
||||||
include a readable copy of the attribution notices contained
|
|
||||||
within such NOTICE file, excluding those notices that do not
|
|
||||||
pertain to any part of the Derivative Works, in at least one
|
|
||||||
of the following places: within a NOTICE text file distributed
|
|
||||||
as part of the Derivative Works; within the Source form or
|
|
||||||
documentation, if provided along with the Derivative Works; or,
|
|
||||||
within a display generated by the Derivative Works, if and
|
|
||||||
wherever such third-party notices normally appear. The contents
|
|
||||||
of the NOTICE file are for informational purposes only and
|
|
||||||
do not modify the License. You may add Your own attribution
|
|
||||||
notices within Derivative Works that You distribute, alongside
|
|
||||||
or as an addendum to the NOTICE text from the Work, provided
|
|
||||||
that such additional attribution notices cannot be construed
|
|
||||||
as modifying the License.
|
|
||||||
|
|
||||||
You may add Your own copyright statement to Your modifications and
|
|
||||||
may provide additional or different license terms and conditions
|
|
||||||
for use, reproduction, or distribution of Your modifications, or
|
|
||||||
for any such Derivative Works as a whole, provided Your use,
|
|
||||||
reproduction, and distribution of the Work otherwise complies with
|
|
||||||
the conditions stated in this License.
|
|
||||||
|
|
||||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
||||||
any Contribution intentionally submitted for inclusion in the Work
|
|
||||||
by You to the Licensor shall be under the terms and conditions of
|
|
||||||
this License, without any additional terms or conditions.
|
|
||||||
Notwithstanding the above, nothing herein shall supersede or modify
|
|
||||||
the terms of any separate license agreement you may have executed
|
|
||||||
with Licensor regarding such Contributions.
|
|
||||||
|
|
||||||
6. Trademarks. This License does not grant permission to use the trade
|
|
||||||
names, trademarks, service marks, or product names of the Licensor,
|
|
||||||
except as required for reasonable and customary use in describing the
|
|
||||||
origin of the Work and reproducing the content of the NOTICE file.
|
|
||||||
|
|
||||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
||||||
agreed to in writing, Licensor provides the Work (and each
|
|
||||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
||||||
implied, including, without limitation, any warranties or conditions
|
|
||||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
||||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
||||||
appropriateness of using or redistributing the Work and assume any
|
|
||||||
risks associated with Your exercise of permissions under this License.
|
|
||||||
|
|
||||||
8. Limitation of Liability. In no event and under no legal theory,
|
|
||||||
whether in tort (including negligence), contract, or otherwise,
|
|
||||||
unless required by applicable law (such as deliberate and grossly
|
|
||||||
negligent acts) or agreed to in writing, shall any Contributor be
|
|
||||||
liable to You for damages, including any direct, indirect, special,
|
|
||||||
incidental, or consequential damages of any character arising as a
|
|
||||||
result of this License or out of the use or inability to use the
|
|
||||||
Work (including but not limited to damages for loss of goodwill,
|
|
||||||
work stoppage, computer failure or malfunction, or any and all
|
|
||||||
other commercial damages or losses), even if such Contributor
|
|
||||||
has been advised of the possibility of such damages.
|
|
||||||
|
|
||||||
9. Accepting Warranty or Additional Liability. While redistributing
|
|
||||||
the Work or Derivative Works thereof, You may choose to offer,
|
|
||||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
||||||
or other liability obligations and/or rights consistent with this
|
|
||||||
License. However, in accepting such obligations, You may act only
|
|
||||||
on Your own behalf and on Your sole responsibility, not on behalf
|
|
||||||
of any other Contributor, and only if You agree to indemnify,
|
|
||||||
defend, and hold each Contributor harmless for any liability
|
|
||||||
incurred by, or claims asserted against, such Contributor by reason
|
|
||||||
of your accepting any such warranty or additional liability.
|
|
||||||
|
|
||||||
END OF TERMS AND CONDITIONS
|
|
||||||
|
|
||||||
APPENDIX: How to apply the Apache License to your work.
|
|
||||||
|
|
||||||
To apply the Apache License to your work, attach the following
|
|
||||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
||||||
replaced with your own identifying information. (Don't include
|
|
||||||
the brackets!) The text should be enclosed in the appropriate
|
|
||||||
comment syntax for the file format. We also recommend that a
|
|
||||||
file or class name and description of purpose be included on the
|
|
||||||
same "printed page" as the copyright notice for easier
|
|
||||||
identification within third-party archives.
|
|
||||||
|
|
||||||
Copyright [yyyy] [name of copyright owner]
|
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
you may not use this file except in compliance with the License.
|
|
||||||
You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
See the License for the specific language governing permissions and
|
|
||||||
limitations under the License.
|
|