@@ -201,7 +258,9 @@ export default function Sidebar() {
)}
-
+
@@ -311,15 +370,15 @@ export default function Sidebar() {
-
-
+
+
-
+
-
diff --git a/src/components/TopBar.tsx b/src/components/TopBar.tsx
index 2ab432b..8d7dd7e 100644
--- a/src/components/TopBar.tsx
+++ b/src/components/TopBar.tsx
@@ -2,20 +2,18 @@ import { useStore } from '@/store/useStore';
import { apiConfig, api } from '@/api/client';
import { useSessionStore } from '@/auth/sessionStore';
import { Button, Field, Input, FilePicker } from './UiKit';
-import { useEffect, useState, useCallback, useRef } from 'react';
+import { useEffect, useState, useCallback } from 'react';
import { navigate } from '@/router/routes';
import { useFeedbackStore } from '@/feedback/feedbackStore';
export default function TopBar() {
- const { apiBase, token, cameraId, viewMode, setImage, image, setViewMode, labelerReturnRoute } = useStore();
+ const { apiBase, token, cameraId, viewMode, setImage, image, imageCameraId, setViewMode, labelerReturnRoute } = useStore();
const sessionAccessToken = useSessionStore(state => state.accessToken);
const [imageUrlInput, setImageUrlInput] = useState('');
const [loadingSnapshot, setLoadingSnapshot] = useState(false);
const notifySuccess = useFeedbackStore(state => state.success);
const notifyError = useFeedbackStore(state => state.error);
const notifyWarning = useFeedbackStore(state => state.warning);
- const currentBlobUrlRef = useRef
(null);
- const loadedCameraIdRef = useRef(null);
const effectiveToken = sessionAccessToken || token;
useEffect(() => {
@@ -27,7 +25,7 @@ export default function TopBar() {
if (!url) return;
try {
const img = await loadImage(url);
- setImage(img);
+ setImage(img, cameraId || undefined);
fitToView(img);
notifySuccess('Изображение открыто.');
} catch (error: any) {
@@ -38,8 +36,7 @@ export default function TopBar() {
const loadByCameraId = useCallback(async () => {
if (!cameraId) return;
- // Prevent duplicate loads for the same camera
- if (loadedCameraIdRef.current === cameraId && image?.url) {
+ if (image?.url && imageCameraId === cameraId) {
return;
}
@@ -49,28 +46,21 @@ export default function TopBar() {
const snap = await api.getSnapshot(parseInt(cameraId, 10));
if (snap?.image_url) {
- // Clean up previous blob URL to prevent memory leaks
- if (currentBlobUrlRef.current && currentBlobUrlRef.current.startsWith('blob:')) {
- URL.revokeObjectURL(currentBlobUrlRef.current);
- }
-
- currentBlobUrlRef.current = snap.image_url;
- loadedCameraIdRef.current = cameraId;
const img = await loadImage(snap.image_url);
- setImage(img);
+ setImage(img, cameraId);
fitToView(img);
} else {
console.warn('Snapshot missing image_url, using fallback');
- const img = await loadImage('/sample.jpg');
- setImage(img);
+ const img = await loadImage('/sample.png');
+ setImage(img, cameraId);
fitToView(img);
notifyWarning('Snapshot недоступен, открыт тестовый кадр.');
}
} catch (error) {
console.error('Error loading snapshot:', error);
try {
- const img = await loadImage('/sample.jpg');
- setImage(img);
+ const img = await loadImage('/sample.png');
+ setImage(img, cameraId);
fitToView(img);
notifyWarning('Не удалось загрузить snapshot, открыт тестовый кадр.');
} catch (fallbackError) {
@@ -80,7 +70,7 @@ export default function TopBar() {
} finally {
setLoadingSnapshot(false);
}
- }, [cameraId, apiBase, effectiveToken, setImage, image]);
+ }, [cameraId, apiBase, effectiveToken, setImage, image, imageCameraId]);
function fitToView(img: { naturalWidth: number; naturalHeight: number; url: string }) {
useStore.getState().setView(1, 0, 0);
@@ -89,6 +79,7 @@ export default function TopBar() {
const isLabeler = viewMode === 'labeler';
function backToOrigin() {
+ setImage(undefined);
if (labelerReturnRoute === 'zones') {
navigate('zones');
return;
@@ -97,21 +88,6 @@ export default function TopBar() {
navigate('cameras');
}
- // Cleanup blob URLs on unmount to prevent memory leaks
- useEffect(() => {
- return () => {
- if (currentBlobUrlRef.current && currentBlobUrlRef.current.startsWith('blob:')) {
- URL.revokeObjectURL(currentBlobUrlRef.current);
- }
- };
- }, []);
-
- useEffect(() => {
- if (viewMode !== 'labeler' || !cameraId) {
- loadedCameraIdRef.current = null;
- }
- }, [viewMode, cameraId]);
-
useEffect(() => {
if (viewMode === 'labeler' && cameraId) {
const timer = setTimeout(() => {
@@ -156,7 +132,7 @@ export default function TopBar() {
try {
const url = URL.createObjectURL(f);
const img = await loadImage(url);
- setImage(img);
+ setImage(img, cameraId || undefined);
fitToView(img);
notifySuccess('Файл изображения открыт.');
} catch (error: any) {
diff --git a/src/store/useStore.ts b/src/store/useStore.ts
index 8c3f886..a850ccf 100644
--- a/src/store/useStore.ts
+++ b/src/store/useStore.ts
@@ -36,6 +36,11 @@ const hasCoordinates = (latitude?: number | null, longitude?: number | null) =>
&& typeof longitude === 'number'
&& Number.isFinite(longitude)
);
+const revokeImage = (image?: ImageMeta) => {
+ if (image?.url.startsWith('blob:')) {
+ URL.revokeObjectURL(image.url);
+ }
+};
type State = {
apiBase: string;
@@ -43,6 +48,7 @@ type State = {
cameraId: string;
labelerReturnRoute?: 'cameras' | 'zones';
image?: ImageMeta;
+ imageCameraId?: string;
cameraMeta?: Camera;
viewMode: ViewMode;
@@ -63,7 +69,7 @@ type State = {
setViewMode(mode: ViewMode): void;
setCamera(id: string): void;
setLabelerReturnRoute(route?: 'cameras' | 'zones'): void;
- setImage(img: ImageMeta | undefined): void;
+ setImage(img: ImageMeta | undefined, cameraId?: string): void;
loadCameraMeta(id: number): Promise;
saveCamera(id: number, patch: Partial): Promise;
@@ -92,6 +98,7 @@ export const useStore = create((set, get) => ({
cameraId: '',
labelerReturnRoute: 'cameras',
image: undefined,
+ imageCameraId: undefined,
cameraMeta: undefined,
viewMode: 'cameras',
tool: 'select',
@@ -103,9 +110,36 @@ export const useStore = create((set, get) => ({
loading: false,
setViewMode(mode) { set({ viewMode: mode }); },
- setCamera(id) { set({ cameraId: id }); },
+ setCamera(id) {
+ set((state) => {
+ if (state.cameraId === id) {
+ return { cameraId: id };
+ }
+ revokeImage(state.image);
+ return {
+ cameraId: id,
+ image: undefined,
+ imageCameraId: undefined,
+ cameraMeta: undefined,
+ zones: [],
+ activeZoneId: undefined,
+ zoneDraft: null,
+ tool: 'select'
+ };
+ });
+ },
setLabelerReturnRoute(route) { set({ labelerReturnRoute: route }); },
- setImage(img) { set({ image: img }); },
+ setImage(img, cameraId) {
+ set((state) => {
+ if (state.image?.url && state.image.url !== img?.url) {
+ revokeImage(state.image);
+ }
+ return {
+ image: img,
+ imageCameraId: img ? cameraId : undefined
+ };
+ });
+ },
async loadCameraMeta(id) {
try {
diff --git a/src/styles.css b/src/styles.css
index aef7023..ca14816 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -714,6 +714,20 @@ hr { border: none; height: 1px; background: var(--border); margin: 10px 0; }
margin-bottom: 12px;
}
+.camera-list-scroll {
+ max-height: clamp(280px, 38vh, 520px);
+ overflow-x: auto;
+ overflow-y: auto;
+}
+
+.camera-list-scroll .camera-list-header {
+ position: sticky;
+ top: 0;
+ z-index: 2;
+ background: #fff;
+ box-shadow: 0 1px 0 #edf1ee;
+}
+
.camera-list-header,
.camera-list-item {
display: grid;