feat: Select article list in sight

This commit is contained in:
2025-05-31 06:35:05 +03:00
parent 5ef61bcef4
commit 2e6917406e
21 changed files with 899 additions and 498 deletions

View File

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

View File

@ -4,3 +4,4 @@ export * from "./ui";
export * from "./store";
export * from "./const";
export * from "./api";
export * from "./modals";

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

View File

@ -0,0 +1 @@
export * from "./SelectArticleDialog";

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

View File

@ -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() {

View File

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

View File

@ -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) => {

View File

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

View File

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

View File

@ -5,3 +5,4 @@ export * from "./VehicleStore";
export * from "./SnapshotStore";
export * from "./SightsStore";
export * from "./CityStore";
export * from "./ArticlesStore";

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

View File

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