Skip to content
Open
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
2 changes: 2 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { Topbar } from './components/Topbar/Topbar';
import { AuthProvider } from './context/AuthProvider';
import { DateLocalizationProvider } from './context/DateLocalizationProvider';
import { PreferencesProvider } from './context/PreferencesProvider';
import { AlertPage } from './pages/AlertPage';
import { AlertsPage } from './pages/AlertsPage';
import { DashboardPage } from './pages/DashboardPage';
import { ErrorPage } from './pages/ErrorPage';
Expand All @@ -41,6 +42,7 @@ const App = () => {
{/* Routes under this cannot be accessed without being logged in */}
<Route element={<ProtectedRoute />}>
<Route path="/alerts" element={<AlertsPage />} />
<Route path="/alert/:alertId" element={<AlertPage />} />
<Route path="/dashboard" element={<DashboardPage />} />
<Route path="/history" element={<HistoryPage />} />
</Route>
Expand Down
40 changes: 39 additions & 1 deletion src/components/Alerts/AlertDetails/AlertContainer.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { Grid } from '@mui/material';
import ShareIcon from '@mui/icons-material/Share';
import { Chip, Grid, Snackbar, useTheme } from '@mui/material';
import { useEffect, useState } from 'react';

import {
type AlertType,
type SequenceWithCameraInfoType,
} from '../../../utils/alerts';
import { useTranslationPrefix } from '../../../utils/useTranslationPrefix';
import { AlertHeader } from './AlertHeader';
import { AlertImages } from './AlertImages/AlertImages';
import { AlertInfos } from './AlertInfos/AlertInfos';
Expand All @@ -22,8 +24,18 @@ export const AlertContainer = ({
alert,
resetAlert,
}: AlertContainerType) => {
const theme = useTheme();
const { t } = useTranslationPrefix('alerts');
const [selectedSequence, setSelectedSequence] =
useState<SequenceWithCameraInfoType | null>(null);
const [isShareSnackbarOpen, setIsShareSnackbarOpen] = useState(false);

const handleShare = () => {
const shareUrl = `${window.location.origin}/alert/${alert.id}`;
void navigator.clipboard.writeText(shareUrl).then(() => {
setIsShareSnackbarOpen(true);
});
};

useEffect(() => {
if (alert.sequences.length > 0) {
Expand All @@ -45,6 +57,26 @@ export const AlertContainer = ({
invalidateAndRefreshData={invalidateAndRefreshData}
/>
</Grid>
<Grid
size={{ xs: 12, lg: 3 }}
container
justifyContent="flex-end"
alignItems="center"
>
<Chip
icon={<ShareIcon />}
label={t('buttonShare')}
variant="filled"
size="medium"
clickable
onClick={handleShare}
sx={{
'& .MuiChip-label': {
font: theme.typography.body1,
},
}}
/>
</Grid>
<Grid size={{ xs: 12, lg: 9 }}>
<AlertImages sequence={selectedSequence} />
</Grid>
Expand All @@ -58,6 +90,12 @@ export const AlertContainer = ({
</Grid>
</Grid>
)}
<Snackbar
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This snackbar is not visible (too far from the button). Maybe we ca reuse the tooltip from the button "CopyToClipboard" ?

open={isShareSnackbarOpen}
autoHideDuration={2000}
onClose={() => setIsShareSnackbarOpen(false)}
message={t('shareLinkCopied')}
/>
</>
);
};
8 changes: 8 additions & 0 deletions src/locales/de/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@
"buttonInvestigate": "Live-Streaming starten",
"buttonTreatAlert": "Alarm bearbeiten",
"buttonModifyAlert": "Alarm ändern",
"buttonShare": "Teilen",
"shareLinkCopied": "Alarm-Link in die Zwischenablage kopiert",
"copyText": "Kopiert",
"blinkingMode": {
"buttonBlinkingView": "Zur Blinkanzeige wechseln",
Expand Down Expand Up @@ -116,6 +118,12 @@
"adding": "Wird hinzugefügt..."
}
},
"alertPage": {
"forbiddenTitle": "Zugriff verweigert",
"forbiddenMessage": "Sie sind nicht angemeldet oder nicht berechtigt, diesen Alarm anzuzeigen.",
"errorMessage": "Auf unserer Seite ist etwas schief gelaufen. Bitte versuchen Sie es später erneut.",
"buttonGoHome": "Zur Startseite"
},
"history": {
"title": "Verlauf",
"noFilterMessage": "Datum auswählen",
Expand Down
8 changes: 8 additions & 0 deletions src/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@
"buttonInvestigate": "Start live streaming",
"buttonTreatAlert": "Treat the alert",
"buttonModifyAlert": "Modify the alert",
"buttonShare": "Share",
"shareLinkCopied": "Alert link copied to clipboard",
"copyText": "Copied",
"blinkingMode": {
"buttonBlinkingView": "Switch to blinking view",
Expand Down Expand Up @@ -115,6 +117,12 @@
"adding": "Adding..."
}
},
"alertPage": {
"forbiddenTitle": "Access denied",
"forbiddenMessage": "You are not logged in or are not authorized to view this alert.",
"errorMessage": "Something went wrong on our side. Please try again later.",
"buttonGoHome": "Go to homepage"
},
"history": {
"title": "History",
"noFilterMessage": "Select a date",
Expand Down
8 changes: 8 additions & 0 deletions src/locales/es/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@
"buttonInvestigate": "Investigar la alerta",
"buttonTreatAlert": "Descartar la alerta",
"buttonModifyAlert": "Modificar la alerta",
"buttonShare": "Compartir",
"shareLinkCopied": "Enlace de la alerta copiado al portapapeles",
"copyText": "Copiado",
"blinkingMode": {
"buttonBlinkingView": "Cambiar a vista parpadeante",
Expand Down Expand Up @@ -115,6 +117,12 @@
"adding": "Agregando..."
}
},
"alertPage": {
"forbiddenTitle": "Acceso denegado",
"forbiddenMessage": "No ha iniciado sesión o no está autorizado para ver esta alerta.",
"errorMessage": "Se ha producido un error. Vuelva a intentarlo más tarde.",
"buttonGoHome": "Ir a la página de inicio"
},
"history": {
"title": "Histórico",
"noFilterMessage": "Seleccione la fecha deseada.",
Expand Down
8 changes: 8 additions & 0 deletions src/locales/fr/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@
"buttonInvestigate": "Activer la levée de doute",
"buttonTreatAlert": "Acquitter l'alerte",
"buttonModifyAlert": "Modifier l'alerte",
"buttonShare": "Partager",
"shareLinkCopied": "Lien de l'alerte copié dans le presse-papier",
"copyText": "Copié",
"blinkingMode": {
"buttonBlinkingView": "Passer en vue clignotante",
Expand Down Expand Up @@ -115,6 +117,12 @@
"adding": "Ajout..."
}
},
"alertPage": {
"forbiddenTitle": "Accès refusé",
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Those labels aren't used. Maybe we can redirect to dedicated pages. We already have an ErrorPage
We might need also a forbidden page

"forbiddenMessage": "Vous n'êtes pas connecté ou vous n'êtes pas autorisé à consulter cette alerte.",
"errorMessage": "Une erreur inattendue est survenue. Veuillez réessayer plus tard.",
"buttonGoHome": "Revenir à la page principale"
},
"history": {
"title": "Historique",
"noFilterMessage": "Sélectionnez la date souhaitée",
Expand Down
97 changes: 97 additions & 0 deletions src/pages/AlertPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import Box from '@mui/material/Box';
import Grid from '@mui/material/Grid';
import Typography from '@mui/material/Typography';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useCallback, useMemo } from 'react';
import { useNavigate, useParams } from 'react-router-dom';

import { DEFAULT_ROUTE } from '@/App';
import { AlertContainer } from '@/components/Alerts/AlertDetails/AlertContainer';
import { Loader } from '@/components/Common/Loader';
import { getAlertById } from '@/services/alerts';
import { STATUS_ERROR, STATUS_LOADING, STATUS_SUCCESS } from '@/services/axios';
import { getCameraList } from '@/services/camera';
import { type AlertType, mapAlertTypeApiToAlertType } from '@/utils/alerts';
import { useIsMobile } from '@/utils/useIsMobile';
import { useTranslationPrefix } from '@/utils/useTranslationPrefix';

export const AlertPage = () => {
const { alertId } = useParams<{ alertId: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { t } = useTranslationPrefix('alerts');
const isMobile = useIsMobile();

const alertIdNumber = Number(alertId);

const { status: statusAlert, data: alertData } = useQuery({
queryKey: ['alert', alertIdNumber],
queryFn: () => getAlertById(alertIdNumber),
enabled: !isNaN(alertIdNumber),
});

const { status: statusCameras, data: cameraList } = useQuery({
queryKey: ['cameras'],
queryFn: getCameraList,
});

const alertsList: AlertType[] = useMemo(
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's weird to have an alerts list in a signel alert page
If the mapper mapAlertTypeApiToAlertType takes only a list, maybe we can reafctor the function like this :
mapOneAlertApiToAlertType(alert: AlertApi, cameras: Camera[])

mapListAlertApiToAlertType(alertList: AlertApi[], cameras: Camera[]) {
alertList.map(alert -> mapOneAlertApiToAlertType(alert, cameras);
}

() =>
mapAlertTypeApiToAlertType(
alertData ? [alertData] : [],
cameraList ?? []
),
[alertData, cameraList]
);
const alert = alertsList[0];

const invalidateAndRefreshData = useCallback(() => {
void queryClient.invalidateQueries({ queryKey: ['alert', alertIdNumber] });
}, [queryClient, alertIdNumber]);

const status = useMemo(() => {
if (statusAlert === STATUS_SUCCESS && statusCameras === STATUS_SUCCESS) {
return STATUS_SUCCESS;
}
if (statusAlert === STATUS_LOADING || statusCameras === STATUS_LOADING) {
return STATUS_LOADING;
}
return STATUS_ERROR;
}, [statusAlert, statusCameras]);

return (
<>
{status === STATUS_LOADING && <Loader />}
{status === STATUS_ERROR && (
<Typography variant="body2">
{t('errorFetchSequencesMessage')}
</Typography>
)}
{status === STATUS_SUCCESS && alertsList.length > 0 && (
<>
{isMobile ? (
<Box height={'100%'} overflow={'auto'}>
<AlertContainer
isLiveMode={false}
alert={alert}
resetAlert={() => void navigate(DEFAULT_ROUTE)}
invalidateAndRefreshData={invalidateAndRefreshData}
/>
</Box>
) : (
<Grid container height="100%">
<Grid size={12} height={'100%'} overflow={'auto'}>
<AlertContainer
isLiveMode={false}
alert={alert}
resetAlert={() => void navigate(DEFAULT_ROUTE)}
invalidateAndRefreshData={invalidateAndRefreshData}
/>
</Grid>
</Grid>
)}
</>
)}
</>
);
};
19 changes: 19 additions & 0 deletions src/services/alerts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,25 @@ const apiAlertListResponseSchema = z.array(apiAlertResponseSchema);
export type DetectionType = z.infer<typeof apiDetectionResponseSchema>;
const apiDetectionListResponseSchema = z.array(apiDetectionResponseSchema);

export const getAlertById = async (
alertId: number
): Promise<AlertTypeApi | null> => {
return apiInstance
.get(`/api/v1/alerts/${alertId.toString()}`)
.then((response: AxiosResponse) => {
try {
const result = apiAlertResponseSchema.safeParse(response.data);
return result.data ?? null;
} catch {
throw new Error('INVALID_API_RESPONSE');
}
})
.catch((err: unknown) => {
console.error(err);
throw err;
});
};

export const getUnlabelledLatestAlerts = async (): Promise<AlertTypeApi[]> => {
return apiInstance
.get('/api/v1/alerts/unlabeled/latest')
Expand Down
Loading