Display images on flight logs
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import "@mantine/core/styles.css";
|
||||
import "@mantine/dates/styles.css";
|
||||
import "@mantine/carousel/styles.css";
|
||||
|
||||
import { cssBundleHref } from "@remix-run/css-bundle";
|
||||
import type { LinksFunction } from "@remix-run/node";
|
||||
|
@@ -1,5 +1,6 @@
|
||||
import CollapsibleFieldset from "@/ui/display/collapsible-fieldset";
|
||||
import { VerticalLogItem } from "@/ui/display/log-item";
|
||||
import SecureImage from "@/ui/display/secure-img";
|
||||
import ErrorDisplay from "@/ui/error-display";
|
||||
import { useApi } from "@/util/api";
|
||||
import {
|
||||
@@ -17,10 +18,12 @@ import {
|
||||
Modal,
|
||||
Button,
|
||||
} from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { Carousel } from "@mantine/carousel";
|
||||
import { randomId, useDisclosure } from "@mantine/hooks";
|
||||
import { useNavigate, useParams } from "@remix-run/react";
|
||||
import { IconPencil, IconTrash } from "@tabler/icons-react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export default function Flight() {
|
||||
const params = useParams();
|
||||
@@ -34,6 +37,14 @@ export default function Flight() {
|
||||
await client.get(`/flights/${params.id}`).then((res) => res.data),
|
||||
});
|
||||
|
||||
const [imageIds, setImageIds] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (flight.data) {
|
||||
setImageIds(flight.data.images ?? []);
|
||||
}
|
||||
}, [flight.data]);
|
||||
|
||||
const [deleteOpened, { open: openDelete, close: closeDelete }] =
|
||||
useDisclosure(false);
|
||||
|
||||
@@ -117,6 +128,25 @@ export default function Flight() {
|
||||
<ScrollArea h="calc(100vh - 95px - 110px)" m="0" p="0">
|
||||
<Container h="100%">
|
||||
<Grid justify="center">
|
||||
{imageIds.length > 0 ? (
|
||||
<CollapsibleFieldset legend="Images" mt="sm" w="100%">
|
||||
<Carousel
|
||||
withIndicators
|
||||
slideGap="sm"
|
||||
slideSize={{ base: "100%", sm: "80%" }}
|
||||
>
|
||||
{imageIds.map((img) => (
|
||||
<Carousel.Slide key={randomId()}>
|
||||
<SecureImage
|
||||
key={randomId()}
|
||||
id={img}
|
||||
radius="lg"
|
||||
/>
|
||||
</Carousel.Slide>
|
||||
))}
|
||||
</Carousel>
|
||||
</CollapsibleFieldset>
|
||||
) : null}
|
||||
<CollapsibleFieldset legend="About" mt="sm" w="100%">
|
||||
<Group grow>
|
||||
<VerticalLogItem label="Date" content={log.date} date />
|
||||
|
91
web/app/ui/display/secure-img.tsx
Normal file
91
web/app/ui/display/secure-img.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { useApi } from "@/util/api";
|
||||
import { Center, Image, Loader, Modal } from "@mantine/core";
|
||||
import { UseQueryResult, useQuery } from "@tanstack/react-query";
|
||||
import ErrorDisplay from "../error-display";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
|
||||
function blobToBase64(blob: Blob): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.readAsBinaryString(blob);
|
||||
reader.onloadend = function () {
|
||||
if (typeof reader.result === "string") {
|
||||
const base64 = btoa(reader.result);
|
||||
resolve(base64);
|
||||
} else {
|
||||
resolve(null);
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function useFetchImageAsBase64(
|
||||
img_id: string
|
||||
): UseQueryResult<{ blob: string | null; type: string }> {
|
||||
const client = useApi();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["image", img_id],
|
||||
queryFn: async (): Promise<{
|
||||
blob: string;
|
||||
type: string;
|
||||
}> => {
|
||||
const response = await client.get(`/img/${img_id}`, {
|
||||
responseType: "arraybuffer",
|
||||
});
|
||||
|
||||
const blob = (await blobToBase64(new Blob([response.data]))) as string;
|
||||
const type = (response.headers["content-type"] as string) ?? "image/jpeg";
|
||||
|
||||
return { blob, type };
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export default function SecureImage({
|
||||
id,
|
||||
radius = "sm",
|
||||
clickable = true,
|
||||
}: {
|
||||
id: string;
|
||||
radius?: string;
|
||||
clickable?: boolean;
|
||||
}) {
|
||||
const { isLoading, error, data } = useFetchImageAsBase64(id);
|
||||
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
|
||||
if (isLoading)
|
||||
return (
|
||||
<Center h="100%">
|
||||
<Loader />
|
||||
</Center>
|
||||
);
|
||||
|
||||
if (error) return <ErrorDisplay error="Failed to load image" />;
|
||||
|
||||
return (
|
||||
<>
|
||||
{clickable ? (
|
||||
<Modal
|
||||
title="Image"
|
||||
opened={opened}
|
||||
onClose={close}
|
||||
centered
|
||||
size="auto"
|
||||
>
|
||||
<Image src={`data:${data?.type};base64,${data?.blob}`} />
|
||||
</Modal>
|
||||
) : null}
|
||||
<Image
|
||||
src={`data:${data?.type};base64,${data?.blob}`}
|
||||
radius={radius}
|
||||
onClick={() => {
|
||||
if (clickable) {
|
||||
open();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
@@ -337,12 +337,32 @@ export default function FlightForm({
|
||||
|
||||
<Fieldset legend="Start/Stop" mt="md">
|
||||
<Group justify="center" grow>
|
||||
<TimeInput form={form} field="time_start" label="Start Time" />
|
||||
<TimeInput form={form} field="time_off" label="Time Off" />
|
||||
<TimeInput
|
||||
form={form}
|
||||
field="time_start"
|
||||
label="Start Time"
|
||||
allowLeadingZeros
|
||||
/>
|
||||
<TimeInput
|
||||
form={form}
|
||||
field="time_off"
|
||||
label="Time Off"
|
||||
allowLeadingZeros
|
||||
/>
|
||||
</Group>
|
||||
<Group justify="center" grow mt="md">
|
||||
<TimeInput form={form} field="time_down" label="Time Down" />
|
||||
<TimeInput form={form} field="time_stop" label="Stop Time" />
|
||||
<TimeInput
|
||||
form={form}
|
||||
field="time_down"
|
||||
label="Time Down"
|
||||
allowLeadingZeros
|
||||
/>
|
||||
<TimeInput
|
||||
form={form}
|
||||
field="time_stop"
|
||||
label="Stop Time"
|
||||
allowLeadingZeros
|
||||
/>
|
||||
</Group>
|
||||
</Fieldset>
|
||||
|
||||
|
@@ -8,10 +8,12 @@ export default function TimeInput({
|
||||
form,
|
||||
label,
|
||||
field,
|
||||
allowLeadingZeros = false,
|
||||
}: {
|
||||
form: UseFormReturnType<FlightFormSchema>;
|
||||
field: string;
|
||||
label: string;
|
||||
allowLeadingZeros?: boolean;
|
||||
}) {
|
||||
const field_key = field as keyof typeof form.getTransformedValues;
|
||||
|
||||
@@ -21,6 +23,7 @@ export default function TimeInput({
|
||||
allowDecimal={false}
|
||||
min={0}
|
||||
max={2359}
|
||||
allowLeadingZeros={allowLeadingZeros}
|
||||
leftSection={
|
||||
<CloseButton
|
||||
aria-label="Clear input"
|
||||
|
@@ -70,6 +70,8 @@ type FlightDisplaySchema = FlightBaseSchema & {
|
||||
time_off: number | null;
|
||||
time_down: number | null;
|
||||
time_stop: number | null;
|
||||
|
||||
images: string[] | null;
|
||||
};
|
||||
|
||||
type FlightConciseSchema = {
|
||||
|
Reference in New Issue
Block a user