Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/api/zones.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,7 @@ function buildZoneCreateBody(zone: ParkingZone) {
zone_type: zone.zone_type,
capacity: zone.capacity,
pay: zone.pay,
geometry: zone.geometry ?? buildZoneGeometry(zone.points),
geometry: buildZoneGeometry(zone.points),
image_polygon: (zone.image_polygon ?? buildImagePolygon(zone.image_quad ?? zone.points)).map(point => [point.x, point.y]),
partner_id: zone.partner_id,
is_active: zone.is_active,
Expand All @@ -298,7 +298,7 @@ function buildZoneUpdateBody(zone: ParkingZone) {
if (zone.is_private !== undefined) body.is_private = zone.is_private;
if (zone.is_accessible !== undefined) body.is_accessible = zone.is_accessible;

body.geometry = zone.geometry ?? buildZoneGeometry(zone.points);
body.geometry = buildZoneGeometry(zone.points);
body.image_polygon = (zone.image_polygon ?? buildImagePolygon(zone.image_quad ?? zone.points)).map(point => [point.x, point.y]);

return body;
Expand Down
69 changes: 64 additions & 5 deletions src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,15 @@ function parseOptionalPositiveInt(value: string): number | null {
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}

function isEditableTarget(target: EventTarget | null) {
if (!(target instanceof HTMLElement)) return false;
const tagName = target.tagName.toLowerCase();
return target.isContentEditable
|| tagName === 'input'
|| tagName === 'textarea'
|| tagName === 'select';
}

export default function Sidebar() {
const s = useStore();
const notifySuccess = useFeedbackStore(state => state.success);
Expand Down Expand Up @@ -142,6 +151,54 @@ export default function Sidebar() {
}
}

useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
if (isEditableTarget(event.target) || event.altKey || event.ctrlKey || event.metaKey) return;

const key = event.key.toLowerCase();
if (key === 'n') {
event.preventDefault();
startDrawZone();
return;
}

if (key === 'escape') {
event.preventDefault();
if (s.tool === 'drawZone') {
s.zoneDraftClear();
notifyInfo('Рисование зоны отменено.');
} else {
s.setTool('select');
notifyInfo('Режим выбора включён.');
}
return;
}

if (!zone) return;

if (key === 'e') {
event.preventDefault();
s.setTool('editZone');
notifyInfo('Редактирование полигона включено.');
return;
}

if (key === 's') {
event.preventDefault();
saveActiveZone();
return;
}

if (key === 'm') {
event.preventDefault();
openZoneOnMap();
}
}

window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [zone?.id, s.tool, s.zoneDraft?.length]);

return (
<div className="sidebar">
<div className="row" style={{justifyContent:'space-between'}}>
Expand Down Expand Up @@ -201,7 +258,9 @@ export default function Sidebar() {
)}

<div className="labeler-action-stack">
<Button onClick={startDrawZone}>+ Добавить зону</Button>
<Button onClick={startDrawZone} title="N">
+ Добавить зону
</Button>
<Button variant="ghost" onClick={openCameraOnMap}>
Отметить камеру на карте
</Button>
Expand Down Expand Up @@ -311,15 +370,15 @@ export default function Sidebar() {
</div>

<div className="labeler-action-grid">
<Button onClick={()=>s.setTool('editZone')}>Редактировать полигон</Button>
<Button variant="ghost" onClick={finishEditing}>Готово</Button>
<Button onClick={()=>s.setTool('editZone')} title="E">Редактировать полигон</Button>
<Button variant="ghost" onClick={finishEditing} title="Esc">Готово</Button>
</div>
<div className="labeler-action-grid">
<Button onClick={saveActiveZone}>Сохранить зону</Button>
<Button onClick={saveActiveZone} title="S">Сохранить зону</Button>
<Button variant="danger" onClick={removeActiveZone}>Удалить зону</Button>
</div>
<div className="labeler-action-grid compact">
<Button variant="ghost" onClick={openZoneOnMap}>
<Button variant="ghost" onClick={openZoneOnMap} title="M">
Геометрия на карте
</Button>
</div>
Expand Down
48 changes: 12 additions & 36 deletions src/components/TopBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null>(null);
const loadedCameraIdRef = useRef<string | null>(null);
const effectiveToken = sessionAccessToken || token;

useEffect(() => {
Expand All @@ -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) {
Expand All @@ -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;
}

Expand All @@ -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) {
Expand All @@ -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);
Expand All @@ -89,6 +79,7 @@ export default function TopBar() {
const isLabeler = viewMode === 'labeler';

function backToOrigin() {
setImage(undefined);
if (labelerReturnRoute === 'zones') {
navigate('zones');
return;
Expand All @@ -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(() => {
Expand Down Expand Up @@ -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) {
Expand Down
40 changes: 37 additions & 3 deletions src/store/useStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,19 @@ 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;
token?: string;
cameraId: string;
labelerReturnRoute?: 'cameras' | 'zones';
image?: ImageMeta;
imageCameraId?: string;
cameraMeta?: Camera;

viewMode: ViewMode;
Expand All @@ -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<void>;
saveCamera(id: number, patch: Partial<Camera>): Promise<boolean>;
Expand Down Expand Up @@ -92,6 +98,7 @@ export const useStore = create<State>((set, get) => ({
cameraId: '',
labelerReturnRoute: 'cameras',
image: undefined,
imageCameraId: undefined,
cameraMeta: undefined,
viewMode: 'cameras',
tool: 'select',
Expand All @@ -103,9 +110,36 @@ export const useStore = create<State>((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 {
Expand Down
14 changes: 14 additions & 0 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading