feat: Add preview_video for sights

This commit is contained in:
2025-07-13 20:26:45 +03:00
parent ced3067915
commit bf117ef048
11 changed files with 437 additions and 38 deletions

View File

@ -50,7 +50,6 @@ export const CityListPage = observer(() => {
} }
setRows(newRows2 || []); setRows(newRows2 || []);
console.log(newRows2);
}, [cities, countryStore.countries, language, isLoading]); }, [cities, countryStore.countries, language, isLoading]);
const columns: GridColDef[] = [ const columns: GridColDef[] = [

View File

@ -17,6 +17,7 @@ export const MEDIA_TYPE_VALUES = {
watermark_rd: 4, watermark_rd: 4,
panorama: 5, panorama: 5,
model: 6, model: 6,
video_preview: 2,
}; };
export const RU_COUNTRIES = [ export const RU_COUNTRIES = [

View File

@ -36,7 +36,13 @@ interface UploadMediaDialogProps {
media_type: number; media_type: number;
}) => void; }) => void;
afterUploadSight?: (id: string) => void; afterUploadSight?: (id: string) => void;
hardcodeType?: "thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null; hardcodeType?:
| "thumbnail"
| "watermark_lu"
| "watermark_rd"
| "image"
| "video_preview"
| null;
contextObjectName?: string; contextObjectName?: string;
contextType?: contextType?:
| "sight" | "sight"
@ -47,6 +53,7 @@ interface UploadMediaDialogProps {
| "station"; | "station";
isArticle?: boolean; isArticle?: boolean;
articleName?: string; articleName?: string;
initialFile?: File; // <--- добавлено
} }
export const UploadMediaDialog = observer( export const UploadMediaDialog = observer(
@ -60,6 +67,7 @@ export const UploadMediaDialog = observer(
isArticle, isArticle,
articleName, articleName,
initialFile, // <--- добавлено
}: UploadMediaDialogProps) => { }: UploadMediaDialogProps) => {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -74,6 +82,17 @@ export const UploadMediaDialog = observer(
[] []
); );
useEffect(() => {
if (initialFile) {
setMediaFile(initialFile);
setMediaFilename(initialFile.name);
setAvailableMediaTypes([2]);
setMediaType(2);
setMediaUrl(URL.createObjectURL(initialFile));
setMediaName(initialFile.name.replace(/\.[^/.]+$/, ""));
}
}, [initialFile]);
useEffect(() => { useEffect(() => {
if (fileToUpload) { if (fileToUpload) {
setMediaFile(fileToUpload); setMediaFile(fileToUpload);
@ -226,6 +245,10 @@ export const UploadMediaDialog = observer(
} }
} }
setSuccess(true); setSuccess(true);
// Закрываем модальное окно после успешного сохранения
setTimeout(() => {
handleClose();
}, 1000); // Небольшая задержка, чтобы пользователь увидел сообщение об успехе
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Failed to save media"); setError(err instanceof Error ? err.message : "Failed to save media");
} finally { } finally {
@ -333,10 +356,15 @@ export const UploadMediaDialog = observer(
<Box className="flex flex-col gap-2 self-end"> <Box className="flex flex-col gap-2 self-end">
<Button <Button
variant="contained" variant="contained"
color="success" sx={{
backgroundColor: isLoading ? "#9e9e9e" : "#4caf50",
"&:hover": {
backgroundColor: isLoading ? "#9e9e9e" : "#45a049",
},
}}
startIcon={ startIcon={
isLoading ? ( isLoading ? (
<CircularProgress size={16} /> <CircularProgress size={16} color="inherit" />
) : ( ) : (
<Save size={16} /> <Save size={16} />
) )
@ -344,7 +372,7 @@ export const UploadMediaDialog = observer(
onClick={handleSave} onClick={handleSave}
disabled={isLoading || (!mediaName && !mediaFilename)} disabled={isLoading || (!mediaName && !mediaFilename)}
> >
Сохранить {isLoading ? "Сохранение..." : "Сохранить"}
</Button> </Button>
</Box> </Box>
</Box> </Box>

View File

@ -12,6 +12,7 @@ class DevicesStore {
getDevices = async () => { getDevices = async () => {
const response = await authInstance.get(`${API_URL}/devices/connected`); const response = await authInstance.get(`${API_URL}/devices/connected`);
runInAction(() => { runInAction(() => {
this.devices = response.data; this.devices = response.data;
}); });

View File

@ -174,6 +174,7 @@ export const DevicesTable = observer(() => {
setSelectedDevice(uuid); // Sets the device in the store, useful for context elsewhere setSelectedDevice(uuid); // Sets the device in the store, useful for context elsewhere
try { try {
await authInstance.post(`/devices/${uuid}/request-status`); await authInstance.post(`/devices/${uuid}/request-status`);
await getVehicles();
await getDevices(); // Refresh devices to show updated status await getDevices(); // Refresh devices to show updated status
} catch (error) { } catch (error) {
console.error(`Error requesting status for device ${uuid}:`, error); console.error(`Error requesting status for device ${uuid}:`, error);
@ -398,7 +399,6 @@ export const DevicesTable = observer(() => {
devices.find((device) => device === row.device_uuid) devices.find((device) => device === row.device_uuid)
) { ) {
await handleReloadStatus(row.device_uuid); await handleReloadStatus(row.device_uuid);
await getDevices();
toast.success("Статус устройства обновлен"); toast.success("Статус устройства обновлен");
} else { } else {
toast.error("Нет связи с устройством"); toast.error("Нет связи с устройством");

View File

@ -59,7 +59,6 @@ export const Layout: React.FC<LayoutProps> = observer(({ children }) => {
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
{(() => { {(() => {
console.log(authStore.payload);
return ( return (
<> <>
<p className=" text-white"> <p className=" text-white">

View File

@ -5,6 +5,10 @@ import {
Autocomplete, Autocomplete,
MenuItem, MenuItem,
Menu as MuiMenu, Menu as MuiMenu,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
} from "@mui/material"; } from "@mui/material";
import { import {
BackButton, BackButton,
@ -20,7 +24,12 @@ import {
UploadMediaDialog, UploadMediaDialog,
MEDIA_TYPE_VALUES, MEDIA_TYPE_VALUES,
} from "@shared"; } from "@shared";
import { ImageUploadCard, LanguageSwitcher } from "@widgets"; import {
ImageUploadCard,
LanguageSwitcher,
VideoPreviewCard,
MediaViewer,
} from "@widgets";
import { Save } from "lucide-react"; import { Save } from "lucide-react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
@ -45,13 +54,15 @@ export const CreateInformationTab = observer(
// Menu state for each media button // Menu state for each media button
const [menuAnchorEl, setMenuAnchorEl] = useState<null | HTMLElement>(null); const [menuAnchorEl, setMenuAnchorEl] = useState<null | HTMLElement>(null);
const [activeMenuType, setActiveMenuType] = useState< const [activeMenuType, setActiveMenuType] = useState<
"thumbnail" | "watermark_lu" | "watermark_rd" | null "thumbnail" | "watermark_lu" | "watermark_rd" | "video_preview" | null
>(null); >(null);
const [isAddMediaOpen, setIsAddMediaOpen] = useState(false); const [isAddMediaOpen, setIsAddMediaOpen] = useState(false);
const [isVideoPreviewOpen, setIsVideoPreviewOpen] = useState(false);
const [hardcodeType, setHardcodeType] = useState< const [hardcodeType, setHardcodeType] = useState<
"thumbnail" | "watermark_lu" | "watermark_rd" | null "thumbnail" | "watermark_lu" | "watermark_rd" | "video_preview" | null
>(null); >(null);
useEffect(() => {}, [hardcodeType]);
// const handleMenuOpen = ( // const handleMenuOpen = (
// event: React.MouseEvent<HTMLElement>, // event: React.MouseEvent<HTMLElement>,
// type: "thumbnail" | "watermark_lu" | "watermark_rd" // type: "thumbnail" | "watermark_lu" | "watermark_rd"
@ -100,7 +111,7 @@ export const CreateInformationTab = observer(
media_name?: string; media_name?: string;
media_type: number; media_type: number;
}, },
type: "thumbnail" | "watermark_lu" | "watermark_rd" type: "thumbnail" | "watermark_lu" | "watermark_rd" | "video_preview"
) => { ) => {
handleChange({ handleChange({
[type]: media.id, [type]: media.id,
@ -108,6 +119,12 @@ export const CreateInformationTab = observer(
setActiveMenuType(null); setActiveMenuType(null);
}; };
const handleVideoPreviewClick = () => {
if (sight.video_preview && sight.video_preview !== "") {
setIsVideoPreviewOpen(true);
}
};
return ( return (
<> <>
<TabPanel value={value} index={index}> <TabPanel value={value} index={index}>
@ -329,6 +346,29 @@ export const CreateInformationTab = observer(
setHardcodeType("watermark_rd"); setHardcodeType("watermark_rd");
}} }}
/> />
<VideoPreviewCard
title="Видео превью"
videoId={sight.video_preview}
onVideoClick={handleVideoPreviewClick}
onDeleteVideoClick={() => {
handleChange({
video_preview: null,
});
}}
onSelectVideoClick={(file) => {
if (file) {
// Если передан файл, открываем диалог загрузки медиа
createSightStore.setFileToUpload(file);
setActiveMenuType("video_preview");
setIsUploadMediaOpen(true);
} else {
// Если файл не передан, открываем диалог выбора существующих медиа
setActiveMenuType("video_preview");
setIsAddMediaOpen(true);
}
}}
/>
</Box> </Box>
</Box> </Box>
</Box> </Box>
@ -390,7 +430,9 @@ export const CreateInformationTab = observer(
setActiveMenuType(null); setActiveMenuType(null);
}} }}
onSelectMedia={(media) => { onSelectMedia={(media) => {
handleMediaSelect(media, activeMenuType ?? "thumbnail"); if (activeMenuType) {
handleMediaSelect(media, activeMenuType);
}
}} }}
mediaType={ mediaType={
activeMenuType activeMenuType
@ -413,14 +455,49 @@ export const CreateInformationTab = observer(
contextObjectName={sight[language].name} contextObjectName={sight[language].name}
contextType="sight" contextType="sight"
afterUpload={(media) => { afterUpload={(media) => {
handleChange({ if (activeMenuType === "video_preview") {
[activeMenuType ?? "thumbnail"]: media.id, handleChange({
}); video_preview: media.id,
});
} else {
handleChange({
[activeMenuType ?? "thumbnail"]: media.id,
});
}
setActiveMenuType(null); setActiveMenuType(null);
setIsUploadMediaOpen(false); setIsUploadMediaOpen(false);
}} }}
hardcodeType={hardcodeType} hardcodeType={activeMenuType}
initialFile={createSightStore.fileToUpload || undefined}
/> />
{/* Модальное окно предпросмотра видео */}
{sight.video_preview && sight.video_preview !== "" && (
<Dialog
open={isVideoPreviewOpen}
onClose={() => setIsVideoPreviewOpen(false)}
maxWidth="md"
fullWidth
>
<DialogTitle>Предпросмотр видео</DialogTitle>
<DialogContent>
<Box className="flex justify-center items-center p-4">
<MediaViewer
media={{
id: sight.video_preview,
media_type: 2,
filename: "video_preview",
}}
/>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => setIsVideoPreviewOpen(false)}>
Закрыть
</Button>
</DialogActions>
</Dialog>
)}
</> </>
); );
} }

View File

@ -5,6 +5,10 @@ import {
Autocomplete, Autocomplete,
MenuItem, MenuItem,
Menu as MuiMenu, Menu as MuiMenu,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
} from "@mui/material"; } from "@mui/material";
import { import {
BackButton, BackButton,
@ -20,7 +24,12 @@ import {
UploadMediaDialog, UploadMediaDialog,
MEDIA_TYPE_VALUES, MEDIA_TYPE_VALUES,
} from "@shared"; } from "@shared";
import { ImageUploadCard, LanguageSwitcher } from "@widgets"; import {
ImageUploadCard,
LanguageSwitcher,
VideoPreviewCard,
MediaViewer,
} from "@widgets";
import { Save } from "lucide-react"; import { Save } from "lucide-react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
@ -45,13 +54,17 @@ export const InformationTab = observer(
// Menu state for each media button // Menu state for each media button
const [menuAnchorEl, setMenuAnchorEl] = useState<null | HTMLElement>(null); const [menuAnchorEl, setMenuAnchorEl] = useState<null | HTMLElement>(null);
const [activeMenuType, setActiveMenuType] = useState< const [activeMenuType, setActiveMenuType] = useState<
"thumbnail" | "watermark_lu" | "watermark_rd" | null "thumbnail" | "watermark_lu" | "watermark_rd" | "video_preview" | null
>(null); >(null);
const [isAddMediaOpen, setIsAddMediaOpen] = useState(false); const [isAddMediaOpen, setIsAddMediaOpen] = useState(false);
const [isVideoPreviewOpen, setIsVideoPreviewOpen] = useState(false);
const [hardcodeType, setHardcodeType] = useState< const [hardcodeType, setHardcodeType] = useState<
"thumbnail" | "watermark_lu" | "watermark_rd" | null "thumbnail" | "watermark_lu" | "watermark_rd" | "video_preview" | null
>(null); >(null);
const { cities } = cityStore; const { cities } = cityStore;
useEffect(() => {}, [hardcodeType]);
useEffect(() => { useEffect(() => {
// Показывать только при инициализации (не менять при ошибках пользователя) // Показывать только при инициализации (не менять при ошибках пользователя)
if (sight.common.latitude !== 0 || sight.common.longitude !== 0) { if (sight.common.latitude !== 0 || sight.common.longitude !== 0) {
@ -74,22 +87,28 @@ export const InformationTab = observer(
handleMenuClose(); handleMenuClose();
}; };
const handleMediaSelect = (media: { const handleMediaSelect = (
id: string; media: {
filename: string; id: string;
media_name?: string; filename: string;
media_type: number; media_name?: string;
}) => { media_type: number;
if (!activeMenuType) return; },
type: "thumbnail" | "watermark_lu" | "watermark_rd" | "video_preview"
) => {
handleChange( handleChange(
language as Language, language as Language,
{ {
[activeMenuType ?? "thumbnail"]: media.id, [type]: media.id,
}, },
true true
); );
};
setIsUploadMediaOpen(false); const handleVideoPreviewClick = () => {
if (sight.common.video_preview && sight.common.video_preview !== "") {
setIsVideoPreviewOpen(true);
}
}; };
const handleChange = ( const handleChange = (
@ -337,6 +356,33 @@ export const InformationTab = observer(
setHardcodeType("watermark_rd"); setHardcodeType("watermark_rd");
}} }}
/> />
<VideoPreviewCard
title="Видео превью"
videoId={sight.common.video_preview}
onVideoClick={handleVideoPreviewClick}
onDeleteVideoClick={() => {
handleChange(
language as Language,
{
video_preview: null,
},
true
);
}}
onSelectVideoClick={(file) => {
if (file) {
// Если передан файл, открываем диалог загрузки медиа
editSightStore.setFileToUpload(file);
setActiveMenuType("video_preview");
setIsUploadMediaOpen(true);
} else {
// Если файл не передан, открываем диалог выбора существующих медиа
setActiveMenuType("video_preview");
setIsAddMediaOpen(true);
}
}}
/>
</Box> </Box>
</Box> </Box>
</Box> </Box>
@ -395,8 +441,13 @@ export const InformationTab = observer(
open={isAddMediaOpen} open={isAddMediaOpen}
onClose={() => { onClose={() => {
setIsAddMediaOpen(false); setIsAddMediaOpen(false);
setActiveMenuType(null);
}}
onSelectMedia={(media) => {
if (activeMenuType) {
handleMediaSelect(media, activeMenuType);
}
}} }}
onSelectMedia={handleMediaSelect}
mediaType={ mediaType={
activeMenuType activeMenuType
? MEDIA_TYPE_VALUES[ ? MEDIA_TYPE_VALUES[
@ -412,23 +463,62 @@ export const InformationTab = observer(
contextObjectName={sight[language].name} contextObjectName={sight[language].name}
contextType="sight" contextType="sight"
afterUpload={(media) => { afterUpload={(media) => {
handleChange( if (activeMenuType === "video_preview") {
language as Language, handleChange(
{ language as Language,
[activeMenuType ?? "thumbnail"]: media.id, {
}, video_preview: media.id,
true },
); true
);
} else {
handleChange(
language as Language,
{
[activeMenuType ?? "thumbnail"]: media.id,
},
true
);
}
setActiveMenuType(null); setActiveMenuType(null);
setIsUploadMediaOpen(false); setIsUploadMediaOpen(false);
}} }}
hardcodeType={hardcodeType} hardcodeType={activeMenuType}
initialFile={editSightStore.fileToUpload || undefined}
/> />
<PreviewMediaDialog <PreviewMediaDialog
open={isPreviewMediaOpen} open={isPreviewMediaOpen}
onClose={() => setIsPreviewMediaOpen(false)} onClose={() => setIsPreviewMediaOpen(false)}
mediaId={mediaId} mediaId={mediaId}
/> />
{/* Модальное окно предпросмотра видео */}
{sight.common.video_preview && sight.common.video_preview !== "" && (
<Dialog
open={isVideoPreviewOpen}
onClose={() => setIsVideoPreviewOpen(false)}
maxWidth="md"
fullWidth
>
<DialogTitle>Предпросмотр видео</DialogTitle>
<DialogContent>
<Box className="flex justify-center items-center p-4">
<MediaViewer
media={{
id: sight.common.video_preview,
media_type: 2,
filename: "video_preview",
}}
/>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => setIsVideoPreviewOpen(false)}>
Закрыть
</Button>
</DialogActions>
</Dialog>
)}
</> </>
); );
} }

View File

@ -0,0 +1,203 @@
import React, { useRef, useState, DragEvent, useEffect } from "react";
import { Paper, Box, Typography, Button, Tooltip } from "@mui/material";
import { X, Info, Plus, Play } from "lucide-react";
import { toast } from "react-toastify";
interface VideoPreviewCardProps {
title: string;
videoId: string | null | undefined;
onVideoClick: () => void;
onDeleteVideoClick: () => void;
onSelectVideoClick: (file?: File) => void;
tooltipText?: string;
}
export const VideoPreviewCard: React.FC<VideoPreviewCardProps> = ({
title,
videoId,
onVideoClick,
onDeleteVideoClick,
onSelectVideoClick,
tooltipText,
}) => {
const fileInputRef = useRef<HTMLInputElement>(null);
const [isDragOver, setIsDragOver] = useState(false);
const token = localStorage.getItem("token");
useEffect(() => {}, [isDragOver]);
// --- Click to select file ---
const handleZoneClick = () => {
// Trigger the hidden file input click
fileInputRef.current?.click();
};
const handleFileInputChange = async (
event: React.ChangeEvent<HTMLInputElement>
) => {
const file = event.target.files?.[0];
if (file) {
if (file.type.startsWith("video/")) {
// Открываем диалог загрузки медиа с файлом видео
onSelectVideoClick(file);
} else {
toast.error("Пожалуйста, выберите видео файл");
}
}
// Reset the input value so selecting the same file again triggers change
event.target.value = "";
};
// --- Drag and Drop Handlers ---
const handleDragOver = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault(); // Crucial to allow a drop
event.stopPropagation();
setIsDragOver(true);
};
const handleDragLeave = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
setIsDragOver(false);
};
const handleDrop = async (event: DragEvent<HTMLDivElement>) => {
event.preventDefault(); // Crucial to allow a drop
event.stopPropagation();
setIsDragOver(false);
const files = event.dataTransfer.files;
if (files && files.length > 0) {
const file = files[0];
if (file.type.startsWith("video/")) {
// Открываем диалог загрузки медиа с файлом видео
onSelectVideoClick(file);
} else {
toast.error("Пожалуйста, выберите видео файл");
}
}
};
return (
<Paper
elevation={2}
sx={{
padding: 2,
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 1,
flex: 1,
minWidth: 150,
}}
>
<Box sx={{ display: "flex", alignItems: "center" }}>
<Typography variant="subtitle2" gutterBottom sx={{ mb: 0, mr: 0.5 }}>
{title}
</Typography>
{tooltipText && (
<Tooltip title={tooltipText}>
<Info size={16} color="gray" style={{ cursor: "pointer" }} />
</Tooltip>
)}
</Box>
<Box
sx={{
position: "relative",
width: "200px",
height: "200px",
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 1,
mb: 1,
cursor: videoId ? "pointer" : "default",
}}
onClick={onVideoClick}
>
{videoId && (
<button
className="absolute top-2 right-2 z-10"
onClick={(e) => {
e.stopPropagation();
onDeleteVideoClick();
}}
>
<X color="red" />
</button>
)}
{videoId ? (
<Box sx={{ position: "relative", width: "100%", height: "100%" }}>
<video
src={`${
import.meta.env.VITE_KRBL_MEDIA
}${videoId}/download?token=${token}`}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
borderRadius: 4,
}}
muted
/>
<Box
sx={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
backgroundColor: "rgba(0, 0, 0, 0.5)",
borderRadius: "50%",
width: 40,
height: 40,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Play size={20} color="white" />
</Box>
</Box>
) : (
<div className="w-full flex flex-col items-center justify-center gap-3">
<div
className="flex flex-col p-5 items-center justify-center gap-3"
style={{
border: "2px dashed #ccc",
borderRadius: 1,
cursor: "pointer",
}}
onClick={handleZoneClick} // Click handler for the zone
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<p className="text-center">Перетащите файл</p>
</div>
<p>или</p>
<Button
variant="contained"
color="primary"
startIcon={<Plus color="white" size={18} />}
onClick={(e) => {
e.stopPropagation(); // Prevent `handleZoneClick` from firing
onSelectVideoClick(); // This button triggers the media selection dialog
}}
>
Выбрать файл
</Button>
{/* Hidden file input */}
<input
type="file"
ref={fileInputRef}
onChange={handleFileInputChange}
style={{ display: "none" }}
accept="video/*" // Accept only video files
/>
</div>
)}
</Box>
</Paper>
);
};

View File

@ -12,6 +12,7 @@ export * from "./MediaArea";
export * from "./ModelViewer3D"; export * from "./ModelViewer3D";
export * from "./MediaAreaForSight"; export * from "./MediaAreaForSight";
export * from "./ImageUploadCard"; export * from "./ImageUploadCard";
export * from "./VideoPreviewCard";
export * from "./LeaveAgree"; export * from "./LeaveAgree";
export * from "./DeleteModal"; export * from "./DeleteModal";
export * from "./SnapshotRestore"; export * from "./SnapshotRestore";

File diff suppressed because one or more lines are too long