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 || []);
console.log(newRows2);
}, [cities, countryStore.countries, language, isLoading]);
const columns: GridColDef[] = [

View File

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

View File

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

View File

@ -12,6 +12,7 @@ class DevicesStore {
getDevices = async () => {
const response = await authInstance.get(`${API_URL}/devices/connected`);
runInAction(() => {
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
try {
await authInstance.post(`/devices/${uuid}/request-status`);
await getVehicles();
await getDevices(); // Refresh devices to show updated status
} catch (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)
) {
await handleReloadStatus(row.device_uuid);
await getDevices();
toast.success("Статус устройства обновлен");
} else {
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 flex-col gap-1">
{(() => {
console.log(authStore.payload);
return (
<>
<p className=" text-white">

View File

@ -5,6 +5,10 @@ import {
Autocomplete,
MenuItem,
Menu as MuiMenu,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
} from "@mui/material";
import {
BackButton,
@ -20,7 +24,12 @@ import {
UploadMediaDialog,
MEDIA_TYPE_VALUES,
} from "@shared";
import { ImageUploadCard, LanguageSwitcher } from "@widgets";
import {
ImageUploadCard,
LanguageSwitcher,
VideoPreviewCard,
MediaViewer,
} from "@widgets";
import { Save } from "lucide-react";
import { observer } from "mobx-react-lite";
@ -45,13 +54,15 @@ export const CreateInformationTab = observer(
// Menu state for each media button
const [menuAnchorEl, setMenuAnchorEl] = useState<null | HTMLElement>(null);
const [activeMenuType, setActiveMenuType] = useState<
"thumbnail" | "watermark_lu" | "watermark_rd" | null
"thumbnail" | "watermark_lu" | "watermark_rd" | "video_preview" | null
>(null);
const [isAddMediaOpen, setIsAddMediaOpen] = useState(false);
const [isVideoPreviewOpen, setIsVideoPreviewOpen] = useState(false);
const [hardcodeType, setHardcodeType] = useState<
"thumbnail" | "watermark_lu" | "watermark_rd" | null
"thumbnail" | "watermark_lu" | "watermark_rd" | "video_preview" | null
>(null);
useEffect(() => {}, [hardcodeType]);
// const handleMenuOpen = (
// event: React.MouseEvent<HTMLElement>,
// type: "thumbnail" | "watermark_lu" | "watermark_rd"
@ -100,7 +111,7 @@ export const CreateInformationTab = observer(
media_name?: string;
media_type: number;
},
type: "thumbnail" | "watermark_lu" | "watermark_rd"
type: "thumbnail" | "watermark_lu" | "watermark_rd" | "video_preview"
) => {
handleChange({
[type]: media.id,
@ -108,6 +119,12 @@ export const CreateInformationTab = observer(
setActiveMenuType(null);
};
const handleVideoPreviewClick = () => {
if (sight.video_preview && sight.video_preview !== "") {
setIsVideoPreviewOpen(true);
}
};
return (
<>
<TabPanel value={value} index={index}>
@ -329,6 +346,29 @@ export const CreateInformationTab = observer(
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>
@ -390,7 +430,9 @@ export const CreateInformationTab = observer(
setActiveMenuType(null);
}}
onSelectMedia={(media) => {
handleMediaSelect(media, activeMenuType ?? "thumbnail");
if (activeMenuType) {
handleMediaSelect(media, activeMenuType);
}
}}
mediaType={
activeMenuType
@ -413,14 +455,49 @@ export const CreateInformationTab = observer(
contextObjectName={sight[language].name}
contextType="sight"
afterUpload={(media) => {
if (activeMenuType === "video_preview") {
handleChange({
video_preview: media.id,
});
} else {
handleChange({
[activeMenuType ?? "thumbnail"]: media.id,
});
}
setActiveMenuType(null);
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,
MenuItem,
Menu as MuiMenu,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
} from "@mui/material";
import {
BackButton,
@ -20,7 +24,12 @@ import {
UploadMediaDialog,
MEDIA_TYPE_VALUES,
} from "@shared";
import { ImageUploadCard, LanguageSwitcher } from "@widgets";
import {
ImageUploadCard,
LanguageSwitcher,
VideoPreviewCard,
MediaViewer,
} from "@widgets";
import { Save } from "lucide-react";
import { observer } from "mobx-react-lite";
@ -45,13 +54,17 @@ export const InformationTab = observer(
// Menu state for each media button
const [menuAnchorEl, setMenuAnchorEl] = useState<null | HTMLElement>(null);
const [activeMenuType, setActiveMenuType] = useState<
"thumbnail" | "watermark_lu" | "watermark_rd" | null
"thumbnail" | "watermark_lu" | "watermark_rd" | "video_preview" | null
>(null);
const [isAddMediaOpen, setIsAddMediaOpen] = useState(false);
const [isVideoPreviewOpen, setIsVideoPreviewOpen] = useState(false);
const [hardcodeType, setHardcodeType] = useState<
"thumbnail" | "watermark_lu" | "watermark_rd" | null
"thumbnail" | "watermark_lu" | "watermark_rd" | "video_preview" | null
>(null);
const { cities } = cityStore;
useEffect(() => {}, [hardcodeType]);
useEffect(() => {
// Показывать только при инициализации (не менять при ошибках пользователя)
if (sight.common.latitude !== 0 || sight.common.longitude !== 0) {
@ -74,22 +87,28 @@ export const InformationTab = observer(
handleMenuClose();
};
const handleMediaSelect = (media: {
const handleMediaSelect = (
media: {
id: string;
filename: string;
media_name?: string;
media_type: number;
}) => {
if (!activeMenuType) return;
},
type: "thumbnail" | "watermark_lu" | "watermark_rd" | "video_preview"
) => {
handleChange(
language as Language,
{
[activeMenuType ?? "thumbnail"]: media.id,
[type]: media.id,
},
true
);
};
setIsUploadMediaOpen(false);
const handleVideoPreviewClick = () => {
if (sight.common.video_preview && sight.common.video_preview !== "") {
setIsVideoPreviewOpen(true);
}
};
const handleChange = (
@ -337,6 +356,33 @@ export const InformationTab = observer(
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>
@ -395,8 +441,13 @@ export const InformationTab = observer(
open={isAddMediaOpen}
onClose={() => {
setIsAddMediaOpen(false);
setActiveMenuType(null);
}}
onSelectMedia={(media) => {
if (activeMenuType) {
handleMediaSelect(media, activeMenuType);
}
}}
onSelectMedia={handleMediaSelect}
mediaType={
activeMenuType
? MEDIA_TYPE_VALUES[
@ -412,6 +463,15 @@ export const InformationTab = observer(
contextObjectName={sight[language].name}
contextType="sight"
afterUpload={(media) => {
if (activeMenuType === "video_preview") {
handleChange(
language as Language,
{
video_preview: media.id,
},
true
);
} else {
handleChange(
language as Language,
{
@ -419,16 +479,46 @@ export const InformationTab = observer(
},
true
);
}
setActiveMenuType(null);
setIsUploadMediaOpen(false);
}}
hardcodeType={hardcodeType}
hardcodeType={activeMenuType}
initialFile={editSightStore.fileToUpload || undefined}
/>
<PreviewMediaDialog
open={isPreviewMediaOpen}
onClose={() => setIsPreviewMediaOpen(false)}
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 "./MediaAreaForSight";
export * from "./ImageUploadCard";
export * from "./VideoPreviewCard";
export * from "./LeaveAgree";
export * from "./DeleteModal";
export * from "./SnapshotRestore";

File diff suppressed because one or more lines are too long