feat: Select article list in sight
This commit is contained in:
@ -1,4 +1,4 @@
|
||||
import { languageStore } from "@shared";
|
||||
import { languageStore, Language } from "@shared";
|
||||
import axios from "axios";
|
||||
|
||||
const authInstance = axios.create({
|
||||
@ -11,4 +11,16 @@ authInstance.interceptors.request.use((config) => {
|
||||
return config;
|
||||
});
|
||||
|
||||
export { authInstance };
|
||||
const languageInstance = (language: Language) => {
|
||||
const instance = axios.create({
|
||||
baseURL: "https://wn.krbl.ru",
|
||||
});
|
||||
instance.interceptors.request.use((config) => {
|
||||
config.headers.Authorization = `Bearer ${localStorage.getItem("token")}`;
|
||||
config.headers["X-Language"] = language;
|
||||
return config;
|
||||
});
|
||||
return instance;
|
||||
};
|
||||
|
||||
export { authInstance, languageInstance };
|
||||
|
@ -4,3 +4,4 @@ export * from "./ui";
|
||||
export * from "./store";
|
||||
export * from "./const";
|
||||
export * from "./api";
|
||||
export * from "./modals";
|
||||
|
188
src/shared/modals/SelectArticleDialog/index.tsx
Normal file
188
src/shared/modals/SelectArticleDialog/index.tsx
Normal file
@ -0,0 +1,188 @@
|
||||
import { articlesStore } from "@shared";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
TextField,
|
||||
List,
|
||||
ListItemButton,
|
||||
ListItemText,
|
||||
Paper,
|
||||
Box,
|
||||
Typography,
|
||||
InputAdornment,
|
||||
} from "@mui/material";
|
||||
import { ImagePlus, Search } from "lucide-react";
|
||||
import { ReactMarkdownComponent } from "@widgets";
|
||||
|
||||
interface SelectArticleModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSelectArticle: (articleId: string) => void;
|
||||
linkedArticleIds?: string[]; // Add optional prop for linked articles
|
||||
}
|
||||
|
||||
export const SelectArticleModal = observer(
|
||||
({
|
||||
open,
|
||||
onClose,
|
||||
onSelectArticle,
|
||||
|
||||
linkedArticleIds = [], // Default to empty array if not provided
|
||||
}: SelectArticleModalProps) => {
|
||||
const { articles, getArticle } = articlesStore; // articles here refers to fetched articles from store
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [hoveredArticleId, setHoveredArticleId] = useState<string | null>(
|
||||
null
|
||||
);
|
||||
const hoverTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (hoveredArticleId) {
|
||||
hoverTimerRef.current = setTimeout(() => {
|
||||
getArticle(hoveredArticleId);
|
||||
}, 200);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (hoverTimerRef.current) {
|
||||
clearTimeout(hoverTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, [hoveredArticleId, getArticle]);
|
||||
|
||||
const handleArticleHover = (articleId: string) => {
|
||||
setHoveredArticleId(articleId);
|
||||
};
|
||||
|
||||
const handleArticleLeave = () => {
|
||||
setHoveredArticleId(null);
|
||||
if (hoverTimerRef.current) {
|
||||
clearTimeout(hoverTimerRef.current);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredArticles = articles
|
||||
.filter((article) => !linkedArticleIds.includes(article.id))
|
||||
.filter((article) =>
|
||||
article.service_name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="lg" fullWidth>
|
||||
<DialogTitle>Выберите существующую статью</DialogTitle>
|
||||
<DialogContent
|
||||
className="flex gap-4"
|
||||
dividers // Adds a divider below the title and above the actions
|
||||
sx={{ height: "600px", display: "flex", flexDirection: "row" }} // Fixed height for DialogContent
|
||||
>
|
||||
<Paper className="w-[66%] flex flex-col">
|
||||
<TextField
|
||||
fullWidth
|
||||
placeholder="Поиск статей..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
sx={{ mb: 2, mt: 1 }}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<Search size={20} />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<List sx={{ flexGrow: 1, overflowY: "auto" }}>
|
||||
{filteredArticles.map((article) => (
|
||||
<ListItemButton
|
||||
key={article.id}
|
||||
onClick={() => onSelectArticle(article.id)}
|
||||
onMouseEnter={() => handleArticleHover(article.id)}
|
||||
onMouseLeave={handleArticleLeave}
|
||||
sx={{
|
||||
borderRadius: 1,
|
||||
mb: 0.5,
|
||||
"&:hover": {
|
||||
backgroundColor: "action.hover",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ListItemText primary={article.service_name} />
|
||||
</ListItemButton>
|
||||
))}
|
||||
</List>
|
||||
</Paper>
|
||||
<Paper className="flex-1 flex flex-col">
|
||||
<Box
|
||||
className="rounded-2xl overflow-hidden"
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
background: "#877361",
|
||||
borderColor: "grey.300",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
{/* Media Preview Area */}
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: 200,
|
||||
flexShrink: 0,
|
||||
backgroundColor: "grey.300",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<ImagePlus size={48} color="grey" />
|
||||
</Box>
|
||||
|
||||
{/* Title Area */}
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: "70px",
|
||||
background: "#877361",
|
||||
display: "flex",
|
||||
flexShrink: 0,
|
||||
alignItems: "center",
|
||||
borderBottom: "1px solid",
|
||||
px: 2,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" color="white">
|
||||
{articlesStore.articleData?.heading ||
|
||||
"Нет данных для предпросмотра"}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Body Preview Area */}
|
||||
<Box
|
||||
sx={{
|
||||
px: 2,
|
||||
flexGrow: 1,
|
||||
overflowY: "auto",
|
||||
backgroundColor: "#877361", // To make markdown readable
|
||||
color: "white",
|
||||
py: 1,
|
||||
}}
|
||||
>
|
||||
<ReactMarkdownComponent
|
||||
value={articlesStore.articleData?.body || ""}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Отмена</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
);
|
1
src/shared/modals/index.ts
Normal file
1
src/shared/modals/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./SelectArticleDialog";
|
36
src/shared/store/ArticlesStore/index.tsx
Normal file
36
src/shared/store/ArticlesStore/index.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
import { authInstance } from "@shared";
|
||||
import { makeAutoObservable, runInAction } from "mobx";
|
||||
|
||||
export type Article = {
|
||||
id: string;
|
||||
heading: string;
|
||||
body: string;
|
||||
service_name: string;
|
||||
};
|
||||
|
||||
class ArticlesStore {
|
||||
constructor() {
|
||||
makeAutoObservable(this);
|
||||
}
|
||||
|
||||
articles: Article[] = [];
|
||||
articleData: Article | null = null;
|
||||
|
||||
getArticles = async () => {
|
||||
const response = await authInstance.get("/article");
|
||||
|
||||
runInAction(() => {
|
||||
this.articles = response.data;
|
||||
});
|
||||
};
|
||||
|
||||
getArticle = async (id: string) => {
|
||||
const response = await authInstance.get(`/article/${id}`);
|
||||
|
||||
runInAction(() => {
|
||||
this.articleData = response.data;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export const articlesStore = new ArticlesStore();
|
@ -1,5 +1,5 @@
|
||||
import { API_URL, decodeJWT } from "@shared";
|
||||
import { makeAutoObservable } from "mobx";
|
||||
import { makeAutoObservable, runInAction } from "mobx";
|
||||
import axios, { AxiosError } from "axios";
|
||||
|
||||
type LoginResponse = {
|
||||
@ -14,6 +14,7 @@ type LoginResponse = {
|
||||
|
||||
class AuthStore {
|
||||
payload: LoginResponse | null = null;
|
||||
token: string | null = null;
|
||||
isLoading = false;
|
||||
error: string | null = null;
|
||||
|
||||
@ -28,21 +29,13 @@ class AuthStore {
|
||||
|
||||
if (decoded) {
|
||||
this.payload = decoded;
|
||||
// Set the token in axios defaults for future requests
|
||||
if (storedToken) {
|
||||
axios.defaults.headers.common[
|
||||
"Authorization"
|
||||
] = `Bearer ${storedToken}`;
|
||||
}
|
||||
} else {
|
||||
// If token is invalid or missing, clear it
|
||||
this.logout();
|
||||
}
|
||||
}
|
||||
|
||||
private setAuthToken(token: string) {
|
||||
localStorage.setItem("token", token);
|
||||
axios.defaults.headers.common["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
login = async (email: string, password: string) => {
|
||||
@ -58,12 +51,13 @@ class AuthStore {
|
||||
}
|
||||
);
|
||||
|
||||
const { token } = response.data;
|
||||
const data = response.data;
|
||||
|
||||
// Update auth token and store state
|
||||
this.setAuthToken(token);
|
||||
this.payload = response.data;
|
||||
this.error = null;
|
||||
runInAction(() => {
|
||||
this.setAuthToken(data.token);
|
||||
this.payload = response.data;
|
||||
this.error = null;
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AxiosError) {
|
||||
this.error =
|
||||
@ -85,7 +79,7 @@ class AuthStore {
|
||||
};
|
||||
|
||||
get isAuthenticated() {
|
||||
return !!this.payload?.token;
|
||||
return this.payload?.token !== null;
|
||||
}
|
||||
|
||||
get user() {
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { authInstance } from "@shared";
|
||||
import { makeAutoObservable } from "mobx";
|
||||
import { makeAutoObservable, runInAction } from "mobx";
|
||||
|
||||
type City = {
|
||||
id: number;
|
||||
@ -18,7 +18,9 @@ class CityStore {
|
||||
|
||||
getCities = async () => {
|
||||
const response = await authInstance.get("/city");
|
||||
this.cities = response.data;
|
||||
runInAction(() => {
|
||||
this.cities = response.data;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { API_URL, authInstance } from "@shared";
|
||||
import { makeAutoObservable } from "mobx";
|
||||
import { makeAutoObservable, runInAction } from "mobx";
|
||||
|
||||
class DevicesStore {
|
||||
devices: string[] = [];
|
||||
@ -12,7 +12,9 @@ class DevicesStore {
|
||||
|
||||
getDevices = async () => {
|
||||
const response = await authInstance.get(`${API_URL}/devices/connected`);
|
||||
this.devices = response.data;
|
||||
runInAction(() => {
|
||||
this.devices = response.data;
|
||||
});
|
||||
};
|
||||
|
||||
setSelectedDevice = (uuid: string) => {
|
||||
|
@ -1,13 +1,14 @@
|
||||
import { makeAutoObservable } from "mobx";
|
||||
import { Language } from "../SightsStore";
|
||||
|
||||
class LanguageStore {
|
||||
language: string = "ru";
|
||||
language: Language = "ru";
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this);
|
||||
}
|
||||
|
||||
setLanguage = (language: string) => {
|
||||
setLanguage = (language: Language) => {
|
||||
this.language = language;
|
||||
};
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { authInstance } from "@shared";
|
||||
import { authInstance, languageInstance, languageStore } from "@shared";
|
||||
import { makeAutoObservable, runInAction } from "mobx";
|
||||
|
||||
export type Language = "ru" | "en" | "zh";
|
||||
@ -8,7 +8,6 @@ export type MultilingualContent = {
|
||||
name: string;
|
||||
description: string;
|
||||
address: string;
|
||||
// Add other fields that need to be multilingual
|
||||
};
|
||||
};
|
||||
|
||||
@ -28,10 +27,22 @@ export type Sight = {
|
||||
video_preview: string | null;
|
||||
};
|
||||
|
||||
export type CreateSight = {
|
||||
[key in Language]: {
|
||||
name: string;
|
||||
description: string;
|
||||
address: string;
|
||||
};
|
||||
};
|
||||
|
||||
class SightsStore {
|
||||
sights: Sight[] = [];
|
||||
sight: Sight | null = null;
|
||||
cachedMultilingualContent: MultilingualContent | null = null;
|
||||
createSight: CreateSight = {
|
||||
ru: { name: "", description: "", address: "" },
|
||||
en: { name: "", description: "", address: "" },
|
||||
zh: { name: "", description: "", address: "" },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this);
|
||||
@ -52,34 +63,96 @@ class SightsStore {
|
||||
});
|
||||
};
|
||||
|
||||
setCachedMultilingualContent = (content: MultilingualContent) => {
|
||||
createSightAction = async (
|
||||
city: number,
|
||||
coordinates: { latitude: number; longitude: number }
|
||||
) => {
|
||||
const id = (
|
||||
await authInstance.post("/sight", {
|
||||
name: this.createSight[languageStore.language].name,
|
||||
description: this.createSight[languageStore.language].description,
|
||||
address: this.createSight[languageStore.language].address,
|
||||
city_id: city,
|
||||
latitude: coordinates.latitude,
|
||||
longitude: coordinates.longitude,
|
||||
})
|
||||
).data.id;
|
||||
|
||||
const anotherLanguages = ["ru", "en", "zh"].filter(
|
||||
(language) => language !== languageStore.language
|
||||
);
|
||||
|
||||
await languageInstance(anotherLanguages[0] as Language).patch(
|
||||
`/sight/${id}`,
|
||||
{
|
||||
name: this.createSight[anotherLanguages[0] as Language].name,
|
||||
description:
|
||||
this.createSight[anotherLanguages[0] as Language].description,
|
||||
address: this.createSight[anotherLanguages[0] as Language].address,
|
||||
city_id: city,
|
||||
latitude: coordinates.latitude,
|
||||
longitude: coordinates.longitude,
|
||||
}
|
||||
);
|
||||
|
||||
await languageInstance(anotherLanguages[1] as Language).patch(
|
||||
`/sight/${id}`,
|
||||
{
|
||||
name: this.createSight[anotherLanguages[1] as Language].name,
|
||||
description:
|
||||
this.createSight[anotherLanguages[1] as Language].description,
|
||||
address: this.createSight[anotherLanguages[1] as Language].address,
|
||||
city_id: city,
|
||||
latitude: coordinates.latitude,
|
||||
longitude: coordinates.longitude,
|
||||
}
|
||||
);
|
||||
|
||||
runInAction(() => {
|
||||
this.cachedMultilingualContent = content;
|
||||
this.createSight = {
|
||||
ru: { name: "", description: "", address: "" },
|
||||
en: { name: "", description: "", address: "" },
|
||||
zh: { name: "", description: "", address: "" },
|
||||
};
|
||||
});
|
||||
};
|
||||
setCreateSight = (content: CreateSight) => {
|
||||
runInAction(() => {
|
||||
this.createSight = content;
|
||||
});
|
||||
};
|
||||
|
||||
updateCachedLanguageContent = (
|
||||
updateCreateSight = (
|
||||
language: Language,
|
||||
content: Partial<MultilingualContent[Language]>
|
||||
content: Partial<CreateSight[Language]>
|
||||
) => {
|
||||
runInAction(() => {
|
||||
if (!this.cachedMultilingualContent) {
|
||||
this.cachedMultilingualContent = {
|
||||
ru: { name: "", description: "", address: "" },
|
||||
en: { name: "", description: "", address: "" },
|
||||
zh: { name: "", description: "", address: "" },
|
||||
};
|
||||
}
|
||||
this.cachedMultilingualContent[language] = {
|
||||
...this.cachedMultilingualContent[language],
|
||||
this.createSight[language] = {
|
||||
...this.createSight[language],
|
||||
...content,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
clearCachedMultilingualContent = () => {
|
||||
clearCreateSight = () => {
|
||||
runInAction(() => {
|
||||
this.cachedMultilingualContent = null;
|
||||
this.createSight = {
|
||||
ru: {
|
||||
name: "",
|
||||
description: "",
|
||||
address: "",
|
||||
},
|
||||
en: {
|
||||
name: "",
|
||||
description: "",
|
||||
address: "",
|
||||
},
|
||||
zh: {
|
||||
name: "",
|
||||
description: "",
|
||||
address: "",
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
}
|
||||
|
@ -5,3 +5,4 @@ export * from "./VehicleStore";
|
||||
export * from "./SnapshotStore";
|
||||
export * from "./SightsStore";
|
||||
export * from "./CityStore";
|
||||
export * from "./ArticlesStore";
|
||||
|
32
src/shared/ui/CoordinatesInput/index.tsx
Normal file
32
src/shared/ui/CoordinatesInput/index.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
import { Box, TextField } from "@mui/material";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export const CoordinatesInput = ({
|
||||
setValue,
|
||||
}: {
|
||||
setValue: (value: { latitude: number; longitude: number }) => void;
|
||||
}) => {
|
||||
const [inputValue, setInputValue] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
setValue({
|
||||
latitude: Number(inputValue.split(" ")[0]),
|
||||
longitude: Number(inputValue.split(" ")[1]),
|
||||
});
|
||||
}, [inputValue]);
|
||||
|
||||
return (
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<TextField
|
||||
label="Координаты"
|
||||
value={inputValue}
|
||||
onChange={(e) => {
|
||||
setInputValue(e.target.value);
|
||||
}}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
helperText="Формат: широта, долгота (например, 59.9398, 30.3146)"
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
@ -1,3 +1,4 @@
|
||||
export * from "./TabPanel";
|
||||
export * from "./BackButton";
|
||||
export * from "./Modal";
|
||||
export * from "./CoordinatesInput";
|
||||
|
Reference in New Issue
Block a user