add web frontend from standalone repo

This commit is contained in:
azpsen 2024-01-30 12:21:07 -06:00
commit 4c6e154f5b
70 changed files with 18322 additions and 0 deletions

80
web/.eslintrc.cjs Normal file
View File

@ -0,0 +1,80 @@
/**
* This is intended to be a basic starting point for linting in your app.
* It relies on recommended configs out of the box for simplicity, but you can
* and should modify this configuration to best suit your team's needs.
*/
/** @type {import('eslint').Linter.Config} */
module.exports = {
root: true,
parserOptions: {
ecmaVersion: "latest",
sourceType: "module",
ecmaFeatures: {
jsx: true,
},
},
env: {
browser: true,
commonjs: true,
es6: true,
},
// Base config
extends: ["eslint:recommended"],
overrides: [
// React
{
files: ["**/*.{js,jsx,ts,tsx}"],
plugins: ["react", "jsx-a11y"],
extends: [
"plugin:react/recommended",
"plugin:react/jsx-runtime",
"plugin:react-hooks/recommended",
"plugin:jsx-a11y/recommended",
],
settings: {
react: {
version: "detect",
},
formComponents: ["Form"],
linkComponents: [
{ name: "Link", linkAttribute: "to" },
{ name: "NavLink", linkAttribute: "to" },
],
},
},
// Typescript
{
files: ["**/*.{ts,tsx}"],
plugins: ["@typescript-eslint", "import"],
parser: "@typescript-eslint/parser",
settings: {
"import/internal-regex": "^~/",
"import/resolver": {
node: {
extensions: [".ts", ".tsx"],
},
typescript: {
alwaysTryTypes: true,
},
},
},
extends: [
"plugin:@typescript-eslint/recommended",
"plugin:import/recommended",
"plugin:import/typescript",
],
},
// Node
{
files: [".eslintrc.js"],
env: {
node: true,
},
},
],
};

6
web/.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
node_modules
/.cache
/build
/public/build
.env

15
web/.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,15 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"command": "npm run dev",
"name": "Run npm run dev",
"type": "node-terminal",
"request": "launch",
"cwd": "${workspaceFolder}"
}
]
}

21
web/LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 April Petersen
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

84
web/README.md Normal file
View File

@ -0,0 +1,84 @@
<p align="center">
<a href="" rel="nooperner">
<img width=200px height=200px src="public/logo.png" alt="Tailfin Logo"></a>
</p>
<h1 align="center">Tailfin</h2>
<h3 align="center">A self-hosted digital flight logbook</h3>
![Screenshots](public/mockup.png)
<p align="center">
<a href="LICENSE"><img src="https://img.shields.io/github/license/azpsen/tailfin-web?style=for-the-badge" /></a>
<a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/typescript-%23007ACC.svg?style=for-the-badge&logo=typescript&logoColor=white" alt="TypeScript" /></a>
<a href="https://react.dev/"><img src="https://img.shields.io/badge/react-%2320232a.svg?style=for-the-badge&logo=react&logoColor=%2361DAFB" alt="React" /></a>
<a href="https://remix.run/"><img src="https://img.shields.io/badge/remix-%23000.svg?style=for-the-badge&logo=remix&logoColor=white" alt="Remix-Run" /></a>
<a href="https://tanstack.com/query/latest/"><img src="https://img.shields.io/badge/-React%20Query-FF4154?style=for-the-badge&logo=react%20query&logoColor=white" alt="TanStack Query" /></a>
</p>
## Table of Contents
- [About](#about)
- [Getting Started](#getting_started)
- [Prerequisites](#prerequisites)
- [Installation](#installation)
- [Configuration](#configuration)
- [Usage](#usage)
- [Roadmap](#roadmap)
## About <a name="about"></a>
Tailfin is a digital flight logbook designed to be hosted on a personal server, computer, or cloud solution. This is the
web frontend.
I created this because I was disappointed with the options available for digital logbooks. The one provided by
ForeFlight is likely most commonly used, but my proclivity towards self-hosting drove me to seek out another solution.
Since I could not find any ready-made self-hosted logbooks, I decided to make my own.
## Getting Started <a name="getting_started"></a>
### Prerequisites <a name="prerequisites"></a>
- npm
- [tailfin-api](https://github.com/azpsen/tailfin-api)
### Installation <a name="installation"></a>
1. Clone the repo
```
$ git clone https://git.github.com/azpsen/tailfin-web.git
$ cd tailfin-web
```
3. Install NPM requirements
```
$ npm install
```
5. Build and run the web app
```
$ npm run build && npm run start
```
### Configuration <a name="configuration"></a>
The URL for the Tailfin API can be set with the environment variable `TAILFIN_API_URL`. It defaults to `http://localhost:8081`, which assumes the API runs on the same machine and uses the default port.
## Usage <a name="usage"></a>
Once running, the web app can be accessed at `localhost:3000`
## Roadmap <a name="roadmap"></a>
- [x] Create, view, edit, and delete flight logs
- [x] Aircraft managment and association with flight logs
- [x] Dashboard with statistics
- [x] Attach photos to log entries
- [ ] GPS track recording and map display
- [ ] Calendar view
- [ ] Admin dashboard to manage all users and server configuration
- [ ] Integrate database of airports and waypoints that can be queried to find nearest

18
web/app/entry.client.tsx Normal file
View File

@ -0,0 +1,18 @@
/**
* By default, Remix will handle hydrating your app on the client for you.
* You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal`
* For more information, see https://remix.run/file-conventions/entry.client
*/
import { RemixBrowser } from "@remix-run/react";
import { startTransition, StrictMode } from "react";
import { hydrateRoot } from "react-dom/client";
startTransition(() => {
hydrateRoot(
document,
<StrictMode>
<RemixBrowser />
</StrictMode>
);
});

140
web/app/entry.server.tsx Normal file
View File

@ -0,0 +1,140 @@
/**
* By default, Remix will handle generating the HTTP Response for you.
* You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal`
* For more information, see https://remix.run/file-conventions/entry.server
*/
import { PassThrough } from "node:stream";
import type { AppLoadContext, EntryContext } from "@remix-run/node";
import { createReadableStreamFromReadable } from "@remix-run/node";
import { RemixServer } from "@remix-run/react";
import isbot from "isbot";
import { renderToPipeableStream } from "react-dom/server";
const ABORT_DELAY = 5_000;
export default function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext,
// This is ignored so we can keep it in the template for visibility. Feel
// free to delete this parameter in your app if you're not using it!
// eslint-disable-next-line @typescript-eslint/no-unused-vars
loadContext: AppLoadContext
) {
return isbot(request.headers.get("user-agent"))
? handleBotRequest(
request,
responseStatusCode,
responseHeaders,
remixContext
)
: handleBrowserRequest(
request,
responseStatusCode,
responseHeaders,
remixContext
);
}
function handleBotRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext
) {
return new Promise((resolve, reject) => {
let shellRendered = false;
const { pipe, abort } = renderToPipeableStream(
<RemixServer
context={remixContext}
url={request.url}
abortDelay={ABORT_DELAY}
/>,
{
onAllReady() {
shellRendered = true;
const body = new PassThrough();
const stream = createReadableStreamFromReadable(body);
responseHeaders.set("Content-Type", "text/html");
resolve(
new Response(stream, {
headers: responseHeaders,
status: responseStatusCode,
})
);
pipe(body);
},
onShellError(error: unknown) {
reject(error);
},
onError(error: unknown) {
responseStatusCode = 500;
// Log streaming rendering errors from inside the shell. Don't log
// errors encountered during initial shell rendering since they'll
// reject and get logged in handleDocumentRequest.
if (shellRendered) {
console.error(error);
}
},
}
);
setTimeout(abort, ABORT_DELAY);
});
}
function handleBrowserRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext
) {
return new Promise((resolve, reject) => {
let shellRendered = false;
const { pipe, abort } = renderToPipeableStream(
<RemixServer
context={remixContext}
url={request.url}
abortDelay={ABORT_DELAY}
/>,
{
onShellReady() {
shellRendered = true;
const body = new PassThrough();
const stream = createReadableStreamFromReadable(body);
responseHeaders.set("Content-Type", "text/html");
resolve(
new Response(stream, {
headers: responseHeaders,
status: responseStatusCode,
})
);
pipe(body);
},
onShellError(error: unknown) {
reject(error);
},
onError(error: unknown) {
responseStatusCode = 500;
// Log streaming rendering errors from inside the shell. Don't log
// errors encountered during initial shell rendering since they'll
// reject and get logged in handleDocumentRequest.
if (shellRendered) {
console.error(error);
}
},
}
);
setTimeout(abort, ABORT_DELAY);
});
}

135
web/app/root.tsx Normal file
View File

@ -0,0 +1,135 @@
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";
import {
Link,
Links,
LiveReload,
Meta,
Outlet,
Scripts,
ScrollRestoration,
isRouteErrorResponse,
json,
useLoaderData,
useRouteError,
} from "@remix-run/react";
import {
Button,
ColorSchemeScript,
Container,
Group,
MantineProvider,
Stack,
Title,
} from "@mantine/core";
import { ModalsProvider } from "@mantine/modals";
import { IconRocket } from "@tabler/icons-react";
import { AuthProvider } from "./util/auth";
import { ApiProvider } from "./util/api";
export const links: LinksFunction = () => [
{
rel: "apple-touch-icon",
href: "/favicon/apple-touch-icon.png",
sizes: "180x180",
},
{
rel: "icon",
href: "/favicon/favicon-32x32.png",
type: "image/png",
sizes: "32x32",
},
{
rel: "icon",
href: "/favicon/favicon-16x16.png",
type: "image/png",
sizes: "16x16",
},
{ rel: "manifest", href: "/favicon/site.webmanifest" },
...(cssBundleHref ? [{ rel: "stylesheet", href: cssBundleHref }] : []),
];
export function ErrorBoundary() {
const error = useRouteError();
return (
<html lang="en">
<head>
<title>Oops!</title>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
<ColorSchemeScript />
</head>
<body>
<MantineProvider>
<Container>
<Stack>
<Title order={2} pt="xl" style={{ textAlign: "center" }}>
{isRouteErrorResponse(error)
? `Error ${error.status} - ${error.statusText}`
: error instanceof Error
? error.message
: "Unknown Error"}
</Title>
<Group justify="center">
<Button
leftSection={<IconRocket />}
component={Link}
to="/logbook"
variant="default"
>
Get me out of here!
</Button>
</Group>
</Stack>
</Container>
</MantineProvider>
</body>
</html>
);
}
export async function loader() {
return json({
ENV: {
TAILFIN_API_URL: process.env.TAILFIN_API_URL ?? "http://localhost:8081",
},
});
}
export default function App() {
const data = useLoaderData<typeof loader>();
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
<ColorSchemeScript />
</head>
<body>
<ApiProvider apiUrl={data.ENV.TAILFIN_API_URL}>
<MantineProvider theme={{ primaryColor: "violet" }}>
<ModalsProvider>
<AuthProvider>
<Outlet />
<ScrollRestoration />
<Scripts />
<LiveReload />
</AuthProvider>
</ModalsProvider>
</MantineProvider>
</ApiProvider>
</body>
</html>
);
}

18
web/app/routes/_index.tsx Normal file
View File

@ -0,0 +1,18 @@
import { useAuth } from "@/util/auth";
import { Outlet, useNavigate } from "@remix-run/react";
import { useEffect } from "react";
export default function Tailfin() {
const { user, loading } = useAuth();
const navigate = useNavigate();
useEffect(() => {
if (!loading && !user) {
navigate("/login");
} else {
navigate("/logbook");
}
}, [user, loading, navigate]);
return <Outlet />;
}

View File

@ -0,0 +1,13 @@
import { Container, Group, Title } from "@mantine/core";
export default function Admin() {
return (
<>
<Container>
<Group justify="space-between" align="center" grow my="lg">
<Title order={2}>Admin</Title>
</Group>
</Container>
</>
);
}

View File

@ -0,0 +1,221 @@
import ErrorDisplay from "@/ui/error-display";
import AircraftForm from "@/ui/form/aircraft-form";
import { useApi } from "@/util/api";
import { useAircraft } from "@/util/hooks";
import { AircraftFormSchema, AircraftSchema } from "@/util/types";
import {
ActionIcon,
Button,
Card,
Center,
Container,
Group,
Loader,
Modal,
ScrollArea,
Stack,
Text,
Title,
Tooltip,
} from "@mantine/core";
import { randomId, useDisclosure } from "@mantine/hooks";
import { IconPencil, IconPlus, IconTrash, IconX } from "@tabler/icons-react";
import {
UseQueryResult,
useMutation,
useQueryClient,
} from "@tanstack/react-query";
function AircraftCard({ aircraft }: { aircraft: AircraftSchema }) {
const [deleteOpened, { open: openDelete, close: closeDelete }] =
useDisclosure(false);
const client = useApi();
const queryClient = useQueryClient();
const deleteAircraft = useMutation({
mutationFn: async () =>
await client.delete(`/aircraft/${aircraft.id}`).then((res) => res.data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["aircraft-list"] });
},
});
const [editOpened, { open: openEdit, close: closeEdit }] =
useDisclosure(false);
const updateAircraft = useMutation({
mutationFn: async (values: AircraftFormSchema) =>
await client
.put(`/aircraft/${aircraft.id}`, values)
.then((res) => res.data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["aircraft-list"] });
},
});
return (
<>
<Modal
opened={deleteOpened}
onClose={closeDelete}
title="Delete Aircraft?"
centered
>
<Stack>
<Text>
Are you sure you want to delete this aircraft? This action cannot be
undone.
</Text>
{deleteAircraft.isError ? (
<Text c="red" fw={700}>
{deleteAircraft.error.message}
</Text>
) : null}
<Group justify="flex-end">
<Button color="red" onClick={() => deleteAircraft.mutate()}>
Delete
</Button>
<Button color="gray" onClick={closeDelete}>
Cancel
</Button>
</Group>
</Stack>
</Modal>
<Modal
opened={editOpened}
onClose={closeEdit}
title="Edit Aircraft"
centered
>
<AircraftForm
isError={updateAircraft.isError}
error={updateAircraft.error}
isPending={updateAircraft.isPending}
onSubmit={(values: AircraftFormSchema) =>
updateAircraft.mutate(values)
}
initialValues={aircraft}
submitButtonLabel="Update"
withCancelButton
cancelFunc={closeEdit}
/>
</Modal>
<Card key={randomId()} withBorder shadow="sm">
<Stack>
<Group grow justify="space-between">
<Title order={4}>{aircraft.tail_no}</Title>
<Group justify="flex-end">
<ActionIcon variant="transparent" onClick={openEdit}>
<IconPencil />
</ActionIcon>
<ActionIcon
variant="transparent"
color="red"
onClick={openDelete}
>
<IconTrash />
</ActionIcon>
</Group>
</Group>
<Group>
<Text>{aircraft.make}</Text>
<Text>{aircraft.model}</Text>
</Group>
<Group>
<Text>{aircraft.aircraft_category}</Text>
<Text>/</Text>
<Text>{aircraft.aircraft_class}</Text>
</Group>
{aircraft.hobbs ? <Text>Hobbs: {aircraft.hobbs}</Text> : null}
</Stack>
</Card>
</>
);
}
function NewAircraftModal({
opened,
close,
}: {
opened: boolean;
close: () => void;
}) {
const client = useApi();
const queryClient = useQueryClient();
const addAircraft = useMutation({
mutationFn: async (values: AircraftFormSchema) => {
const newAircraft = values;
if (newAircraft) {
const res = await client.post("/aircraft", newAircraft);
return res.data;
}
throw new Error("Aircraft creation failed");
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["aircraft-list"] });
close();
},
});
return (
<Modal opened={opened} onClose={close} title="New Aircraft" centered>
<AircraftForm
onSubmit={addAircraft.mutate}
isError={addAircraft.isError}
error={addAircraft.error}
isPending={addAircraft.isPending}
submitButtonLabel="Add"
/>
</Modal>
);
}
export default function Aircraft() {
const aircraft: UseQueryResult<AircraftSchema[]> = useAircraft();
const [newOpened, { open: openNew, close: closeNew }] = useDisclosure(false);
return (
<>
<NewAircraftModal opened={newOpened} close={closeNew} />
<Container>
<Group justify="space-between" align="center" grow my="lg">
<Title order={2}>Aircraft</Title>
<Group justify="flex-end">
<Tooltip label="Add Aircraft">
<ActionIcon variant="subtle" onClick={openNew}>
<IconPlus />
</ActionIcon>
</Tooltip>
</Group>
</Group>
<ScrollArea h="calc(100vh - 95px - 75px)">
{aircraft.isLoading ? (
<Center h="calc(100vh - 95px - 75px)">
<Loader />
</Center>
) : aircraft.isError ? (
<Center h="calc(100vh - 95px - 75px)">
<ErrorDisplay error={aircraft.error?.message} />
</Center>
) : aircraft.data && aircraft.data.length === 0 ? (
<Center h="calc(100vh - 95px - 75px)">
<Stack align="center">
<IconX size="3rem" />
<Text c="dimmed">No Aircraft</Text>
</Stack>
</Center>
) : (
<Stack justify="center">
{aircraft.data?.map((item) => (
<AircraftCard key={randomId()} aircraft={item} />
))}
</Stack>
)}
</ScrollArea>
</Container>
</>
);
}

View File

@ -0,0 +1,153 @@
import CollapsibleFieldset from "@/ui/display/collapsible-fieldset";
import { VerticalLogItem } from "@/ui/display/log-item";
import ErrorDisplay from "@/ui/error-display";
import { useApi } from "@/util/api";
import {
Center,
Text,
Group,
Loader,
Container,
Stack,
Title,
} from "@mantine/core";
import { randomId } from "@mantine/hooks";
import { useQuery } from "@tanstack/react-query";
import { useEffect, useState } from "react";
export default function Dashboard() {
const client = useApi();
const [totalsData, setTotalsData] = useState<{
by_class: object;
totals: object;
} | null>(null);
const totals = useQuery({
queryKey: ["totals"],
queryFn: async () =>
await client.get(`/flights/totals`).then((res) => res.data),
});
useEffect(() => {
if (totals.isFetched && !!totals.data) {
setTotalsData(totals.data);
}
}, [totals.data]);
return (
<Container>
{totals.isLoading ? (
<Center h="calc(100vh - 95px)">
<Loader />
</Center>
) : totals.isError ? (
<Center h="calc(100vh - 95px)">
<ErrorDisplay error={totals.error?.message} />
</Center>
) : (
<Stack align="center" mt="xl">
<Title order={3}>Totals</Title>
<CollapsibleFieldset legend="Time" w="100%">
<Group grow>
<VerticalLogItem
label="All"
content={totalsData?.totals?.time_total ?? 0.0}
/>
<VerticalLogItem
label="Solo"
content={totalsData?.totals?.time_solo ?? 0.0}
/>
<VerticalLogItem
label="PIC"
content={totalsData?.totals?.time_pic ?? 0.0}
/>
<VerticalLogItem
label="SIC"
content={totalsData?.totals?.time_sic ?? 0.0}
/>
<VerticalLogItem
label="Instrument"
content={totalsData?.totals?.time_instrument ?? 0.0}
/>
<VerticalLogItem
label="Simulator"
content={totalsData?.totals?.time_sim ?? 0.0}
/>
</Group>
</CollapsibleFieldset>
<CollapsibleFieldset legend="Landings" w="100%">
<Group grow>
<VerticalLogItem
label="Day"
content={totalsData?.totals?.landings_day ?? 0}
/>
<VerticalLogItem
label="Night"
content={totalsData?.totals?.landings_night ?? 0}
/>
</Group>
</CollapsibleFieldset>
<CollapsibleFieldset legend="Cross-Country" w="100%">
<Group grow>
<VerticalLogItem
label="Hours"
content={totalsData?.totals?.time_xc ?? 0.0}
/>
<VerticalLogItem
label="Dual Recvd"
content={totalsData?.totals?.xc_dual_recvd ?? 0.0}
/>
<VerticalLogItem
label="Solo"
content={totalsData?.totals?.xc_solo ?? 0.0}
/>
<VerticalLogItem
label="PIC"
content={totalsData?.totals?.xc_pic ?? 0.0}
/>
</Group>
</CollapsibleFieldset>
<CollapsibleFieldset legend="Night" w="100%">
<Group grow>
<VerticalLogItem
label="Hours"
content={totalsData?.totals?.time_night ?? 0.0}
/>
<VerticalLogItem
label="Night Dual Received"
content={totalsData?.totals?.night_dual_recvd ?? 0.0}
/>
<VerticalLogItem
label="Night PIC"
content={totalsData?.totals?.night_pic ?? 0.0}
/>
</Group>
</CollapsibleFieldset>
<CollapsibleFieldset legend="By Category / Class" w="100%">
<Group justify="center" grow>
{totalsData?.by_class?.map((category) => (
<Stack key={randomId()} gap="0">
<Text pb="xs" style={{ textAlign: "center" }}>
{category.aircraft_category}
</Text>
<Group justify="center" grow>
{category.classes.map((total) => (
<>
<VerticalLogItem
key={randomId()}
label={total.aircraft_class}
content={total.time_total ?? 0.0}
/>
</>
))}
</Group>
</Stack>
))}
</Group>
</CollapsibleFieldset>
</Stack>
)}
</Container>
);
}

View File

@ -0,0 +1,403 @@
import CollapsibleFieldset from "@/ui/display/collapsible-fieldset";
import { VerticalLogItem } from "@/ui/display/log-item";
import ErrorDisplay from "@/ui/error-display";
import { useApi } from "@/util/api";
import {
ActionIcon,
Center,
Container,
Grid,
Group,
Loader,
ScrollArea,
Stack,
Title,
Tooltip,
Text,
Modal,
Button,
} from "@mantine/core";
import { 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";
import { AircraftLogItem } from "@/ui/display/editable/aircraft-log-item";
import { DateLogItem } from "@/ui/display/editable/date-log-item";
import { HourLogItem } from "@/ui/display/editable/hour-log-item";
import { IntLogItem } from "@/ui/display/editable/int-log-item";
import { ListLogItem } from "@/ui/display/editable/list-log-item";
import { TimeLogItem } from "@/ui/display/editable/time-log-item";
import { TextLogItem } from "@/ui/display/editable/text-log-item";
import ImageLogItem from "@/ui/display/editable/img-log-item";
export default function Flight() {
const params = useParams();
const client = useApi();
const navigate = useNavigate();
const flight = useQuery({
queryKey: [params.id],
queryFn: async () =>
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);
const deleteFlight = useMutation({
mutationFn: async () =>
await client.delete(`/flights/${params.id}`).then((res) => res.data),
onSuccess: () => {
navigate("/logbook/flights");
},
});
const log = flight.data;
return (
<>
<Modal
opened={deleteOpened}
onClose={closeDelete}
title="Delete Flight?"
centered
>
<Stack>
<Text>
Are you sure you want to delete this flight? This action cannot be
undone.
</Text>
{deleteFlight.isError ? (
<Text c="red" fw={700}>
{deleteFlight.error.message}
</Text>
) : null}
<Group justify="flex-end">
{deleteFlight.isPending ? <Loader /> : null}
<Button color="red" onClick={() => deleteFlight.mutate()}>
Delete
</Button>
<Button color="gray" onClick={closeDelete}>
Cancel
</Button>
</Group>
</Stack>
</Modal>
<Container>
<Stack h="calc(100vh-95px)">
{flight.isError ? (
<Center h="calc(100vh - 95px)">
<ErrorDisplay error="Error Fetching Flight" />
</Center>
) : flight.isPending ? (
<Center h="calc(100vh - 95px)">
<Loader />
</Center>
) : flight.data ? (
<>
<Group justify="space-between" px="xl">
<Title order={2} py="lg" style={{ textAlign: "center" }}>
Flight Log
</Title>
<Group>
<Tooltip
label="Edit Flight"
onClick={() =>
navigate(`/logbook/flights/edit/${params.id}`)
}
>
<ActionIcon variant="subtle" size="lg">
<IconPencil />
</ActionIcon>
</Tooltip>
<Tooltip label="Delete Flight">
<ActionIcon
variant="subtle"
size="lg"
color="red"
onClick={openDelete}
>
<IconTrash />
</ActionIcon>
</Tooltip>
</Group>
</Group>
<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%">
<ImageLogItem
imageIds={imageIds}
id={params.id ?? ""}
mah="700px"
/>
</CollapsibleFieldset>
) : null}
<CollapsibleFieldset legend="About" mt="sm" w="100%">
<Group grow>
<DateLogItem
label="Date"
content={log.date}
id={params.id}
field="date"
/>
<AircraftLogItem
label="Aircraft"
content={log.aircraft}
id={params.id}
field="aircraft"
/>
</Group>
{(log.pax || log.crew) &&
(log.pax.length > 0 || log.crew.length > 0) ? (
<Group grow mt="sm">
<ListLogItem
label="Pax"
content={log.pax}
listColor="gray"
id={params.id}
field="pax"
/>
<ListLogItem
label="Crew"
content={log.crew}
listColor="gray"
id={params.id}
field="crew"
/>
</Group>
) : null}
{log.tags && log.tags.length > 0 ? (
<Group grow mt="sm">
<ListLogItem
label="Tags"
content={log.tags}
id={params.id}
field="tags"
/>
</Group>
) : null}
{log.comments?.length > 0 ? (
<Group grow mt="sm">
<TextLogItem
label="Comments"
content={log.comments}
id={params.id}
field="comments"
/>
</Group>
) : null}
</CollapsibleFieldset>
{log.waypoint_from || log.waypoint_to || log.route ? (
<CollapsibleFieldset legend="Route" w="100%" mt="sm">
{log.waypoint_from || log.waypoint_to ? (
<Group grow>
<TextLogItem
label="From"
content={log.waypoint_from}
id={params.id}
field="waypoint_from"
/>
<TextLogItem
label="To"
content={log.waypoint_to}
id={params.id}
field="waypoint_to"
/>
</Group>
) : null}
{log.route ? (
<Group grow>
<VerticalLogItem
label="Route"
content={log.route}
/>
</Group>
) : null}
</CollapsibleFieldset>
) : null}
{log.hobbs_start || log.hobbs_end ? (
<CollapsibleFieldset legend="Times" w="100%" mt="sm">
<Group grow>
<HourLogItem
label="Hobbs Start"
content={log.hobbs_start}
id={params.id}
field="hobbs_start"
/>
<HourLogItem
label="Hobbs End"
content={log.hobbs_end}
id={params.id}
field="hobbs_end"
/>
</Group>
</CollapsibleFieldset>
) : null}
{log.time_start ||
log.time_off ||
log.time_down ||
log.time_stop ? (
<CollapsibleFieldset legend="Start/Stop" w="100%" mt="sm">
{log.time_start || log.time_off ? (
<Group grow>
<TimeLogItem
label="Time Start"
content={log.time_start}
date={log.date}
id={params.id}
field="time_start"
/>
<TimeLogItem
label="Time Off"
content={log.time_off}
date={log.date}
id={params.id}
field="time_off"
/>
</Group>
) : null}
{log.time_down || log.time_stop ? (
<Group grow mt="sm">
<TimeLogItem
label="Time Down"
content={log.time_down}
date={log.date}
id={params.id}
field="time_down"
/>
<TimeLogItem
label="Time Stop"
content={log.time_stop}
date={log.date}
id={params.id}
field="time_stop"
/>
</Group>
) : null}
</CollapsibleFieldset>
) : null}
<CollapsibleFieldset legend="Hours" w="100%" mt="sm">
<Group grow>
<HourLogItem
label="Total"
content={log.time_total}
id={params.id}
field="time_total"
/>
<HourLogItem
label="Solo"
content={log.time_solo}
id={params.id}
field="time_solo"
/>
<HourLogItem
label="Night"
content={log.time_night}
id={params.id}
field="time_night"
/>
</Group>
<Group grow mt="sm">
<HourLogItem
label="PIC"
content={log.time_pic}
id={params.id}
field="time_pic"
/>
<HourLogItem
label="SIC"
content={log.time_sic}
id={params.id}
field="time_sic"
/>
</Group>
</CollapsibleFieldset>
{log.time_xc || log.dist_xc ? (
<CollapsibleFieldset
legend="Cross-Country"
w="100%"
mt="sm"
>
<Group grow>
<HourLogItem
label="Hours"
content={log.time_xc}
id={params.id}
field="time_xc"
/>
<VerticalLogItem
label="Distance"
content={log.dist_xc}
decimal={2}
/>
</Group>
</CollapsibleFieldset>
) : null}
<CollapsibleFieldset legend="Landings" w="100%">
<Group grow>
<IntLogItem
label="Day"
content={log.landings_day}
id={params.id}
field="landings_day"
/>
<IntLogItem
label="Night"
content={log.landings_night}
id={params.id}
field="landings_night"
/>
</Group>
</CollapsibleFieldset>
{log.time_instrument ||
log.time_sim_instrument ||
log.holds_instrument ? (
<CollapsibleFieldset legend="Instrument" mt="sm" w="100%">
<Group grow>
<HourLogItem
label="Instrument Time"
content={log.time_instrument}
id={params.id}
field="time_instrument"
/>
<HourLogItem
label="Simulated Instrument Time"
content={log.time_sim_instrument}
id={params.id}
/>
<IntLogItem
label="Instrument Holds"
content={log.holds_instrument}
id={params.id}
field="holds_instrument"
/>
</Group>
</CollapsibleFieldset>
) : null}
</Grid>
</Container>
</ScrollArea>
</>
) : (
<Center h="calc(100vh - 95px)">
<ErrorDisplay error="Unknown Error" />
</Center>
)}
</Stack>
</Container>
</>
);
}

View File

@ -0,0 +1,19 @@
import { Center, Container, Stack } from "@mantine/core";
import { MobileFlightsList } from "@/routes/logbook/flights/flights-list";
import { IconFeather } from "@tabler/icons-react";
export default function Flights() {
return (
<>
<Container visibleFrom="lg" h="calc(100vh - 95px)">
<Stack align="center" justify="center" h="100%">
<IconFeather size="3rem" />
<Center>Select a flight</Center>
</Stack>
</Container>
<Container hiddenFrom="lg">
<MobileFlightsList />
</Container>
</>
);
}

View File

@ -0,0 +1,111 @@
import { Center, Container, Loader, Stack, Title } from "@mantine/core";
import {
FlightFormSchema,
flightCreateHelper,
flightEditHelper,
} from "@/util/types";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useApi } from "@/util/api";
import { useNavigate, useParams } from "@remix-run/react";
import { AxiosError } from "axios";
import FlightForm from "@/ui/form/flight-form";
import ErrorDisplay from "@/ui/error-display";
export default function EditFlight() {
const params = useParams();
const navigate = useNavigate();
const queryClient = useQueryClient();
const client = useApi();
const flight = useQuery({
queryKey: [params.id],
queryFn: async () =>
await client.get(`/flights/${params.id}`).then((res) => res.data),
});
const editFlight = useMutation({
mutationFn: async (values: FlightFormSchema) => {
const newFlight = flightCreateHelper(values);
if (newFlight) {
const existing_img = values.existing_images ?? [];
const missing = flight.data.images.filter(
(item: string) => existing_img?.indexOf(item) < 0
);
for (const img of missing) {
await client.delete(`/img/${img}`);
}
const res = await client.put(`/flights/${params.id}`, {
...newFlight,
images: values.existing_images,
});
// Upload images
if (values.images.length > 0) {
const imageForm = new FormData();
for (const img of values.images ?? []) {
imageForm.append("images", img);
}
const img_id = await client.post(
`/flights/${params.id}/add_images`,
imageForm,
{ headers: { "Content-Type": "multipart/form-data" } }
);
if (!img_id) {
await queryClient.invalidateQueries({ queryKey: ["flights-list"] });
throw new Error("Image upload failed");
}
}
return res.data;
}
throw new Error("Flight updating failed");
},
retry: (failureCount, error: AxiosError) => {
return !error || error.response?.status !== 401;
},
onSuccess: async (data: { id: string }) => {
await queryClient.invalidateQueries({ queryKey: ["flights-list"] });
navigate(`/logbook/flights/${data.id}`);
},
});
return (
<Container>
<Stack>
<Title order={2}>Edit Flight</Title>
{flight.isLoading ? (
<Center h="calc(100vh - 95px - 110px)">
<Loader />
</Center>
) : flight.isError ? (
<Center h="calc(100vh - 95px - 110px)">
<ErrorDisplay error={flight.error.message} />
</Center>
) : (
<FlightForm
initialValues={
flight.data ? flightEditHelper(flight.data) ?? null : null
}
onSubmit={editFlight.mutate}
isPending={editFlight.isPending}
isError={editFlight.isError}
error={editFlight.error}
submitButtonLabel="Update"
withCancelButton
cancelFunc={() => navigate(`/logbook/flights/${params.id}`)}
mah="calc(100vh - 95px - 110px)"
autofillHobbs={false}
/>
)}
</Stack>
</Container>
);
}

View File

@ -0,0 +1,327 @@
import ErrorDisplay from "@/ui/error-display";
import { useApi } from "@/util/api";
import { useAircraft } from "@/util/hooks";
import { AircraftSchema, FlightConciseSchema } from "@/util/types";
import {
NavLink,
Text,
Button,
ScrollArea,
Stack,
Loader,
Center,
Badge,
Group,
Divider,
Select,
} from "@mantine/core";
import { randomId } from "@mantine/hooks";
import { Link, useLocation, useNavigate, useParams } from "@remix-run/react";
import {
IconArrowRightTail,
IconPlaneTilt,
IconPlus,
IconX,
} from "@tabler/icons-react";
import {
UseQueryResult,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { useEffect, useRef, useState } from "react";
function FlightsListDisplay({
flights,
}: {
flights: UseQueryResult<{
[year: string]: {
[month: string]: { [day: string]: FlightConciseSchema[] };
};
}>;
}) {
const monthNames = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
const params = useParams();
useEffect(() => {
console.log(params);
if (params.id) {
const selectedFlight = document.getElementById(`${params.id} navlink`);
console.log(selectedFlight);
selectedFlight?.scrollIntoView({ block: "center", inline: "center" });
}
}, [flights.data]);
return (
<>
{flights.data ? (
Object.entries(flights.data)?.length === 0 ? (
<Center h="calc(100vh - 95px - 50px)">
<Stack align="center">
<IconX size="3rem" />
<Center>No flights</Center>
</Stack>
</Center>
) : (
Object.entries(flights.data)
.reverse()
.map(([year, months]) => (
<>
<NavLink
key={randomId()}
label={`-- ${year} --`}
fw={700}
style={{ textAlign: "center" }}
defaultOpened
childrenOffset={0}
>
<>
<Divider />
{Object.entries(months)
.reverse()
.map(([month, days]) => (
<NavLink
key={randomId()}
label={monthNames[Number(month) - 1]}
fw={500}
style={{ textAlign: "center" }}
defaultOpened
>
<Divider />
{Object.entries(days)
.reverse()
.map(([, logs]) => (
<>
{logs
.reverse()
.map((flight: FlightConciseSchema) => (
<>
<NavLink
key={randomId()}
id={`${flight.id} navlink`}
component={Link}
to={`/logbook/flights/${flight.id}`}
label={
<Group>
<Badge
color="gray"
size="lg"
radius="sm"
px="xs"
>
{flight.date}
</Badge>
<Text fw={500}>
{`${Number(
flight.time_total
).toFixed(1)} hr`}
</Text>
{flight.waypoint_from ||
flight.waypoint_to ? (
<>
<Text>/</Text>
<Group gap="xs">
{flight.waypoint_from ? (
<Text>
{flight.waypoint_from}
</Text>
) : (
""
)}
{flight.waypoint_from &&
flight.waypoint_to ? (
<IconArrowRightTail />
) : null}
{flight.waypoint_to ? (
<Text>
{flight.waypoint_to}
</Text>
) : (
""
)}
</Group>
</>
) : null}
</Group>
}
description={
<Text lineClamp={1}>
{flight.comments
? flight.comments
: "(No Comment)"}
</Text>
}
rightSection={
flight.aircraft ? (
<Badge
key={randomId()}
leftSection={
<IconPlaneTilt size="1rem" />
}
color="gray"
size="lg"
>
{flight.aircraft}
</Badge>
) : null
}
active={params.id === flight.id}
/>
<Divider />
</>
))}
</>
))}
</NavLink>
))}
</>
</NavLink>
</>
))
)
) : flights.isLoading ? (
<Center h="calc(100vh - 95px - 50px)">
<Loader />
</Center>
) : flights.isError ? (
<ErrorDisplay error={flights.error?.message} />
) : (
<Center h="calc(100vh - 95px - 50px)">
<Text p="sm">No Flights</Text>
</Center>
)}
</>
);
}
function AircraftFilter({
aircraft,
setAircraft,
query = "flights-list",
}: {
aircraft: string;
setAircraft: (aircraft: string) => void;
query?: string;
}) {
const getAircraft = useAircraft();
const queryClient = useQueryClient();
return (
<Select
placeholder="Filter by Aircraft..."
data={
getAircraft.isFetched
? getAircraft.data?.map((item: AircraftSchema) => ({
value: item.tail_no,
label: item.tail_no,
}))
: ""
}
value={aircraft}
onChange={(_value, option) => {
setAircraft(option?.label ?? "");
queryClient.invalidateQueries({
queryKey: [query, aircraft],
});
}}
clearable
/>
);
}
export function FlightsList() {
const location = useLocation();
const page = location.pathname.split("/")[3];
const [aircraft, setAircraft] = useState("");
const client = useApi();
// const flights = useFlights("aircraft", aircraft);
const flights = useQuery({
queryKey: ["flights-list", aircraft],
queryFn: async () =>
await client
.get(
`/flights/by-date?order=1${
aircraft !== "" ? `&filter=aircraft&filter_val=${aircraft}` : ""
}`
)
.then((res) => res.data),
});
const navigate = useNavigate();
return (
<Stack p="0" m="0" gap="0">
<Group grow preventGrowOverflow={false}>
<AircraftFilter aircraft={aircraft} setAircraft={setAircraft} />
<Button
variant="outline"
leftSection={<IconPlus />}
onClick={() => navigate("/logbook/flights/new")}
>
New Flight
</Button>
</Group>
<ScrollArea h="calc(100vh - 95px - 50px)">
<FlightsListDisplay flights={flights} page={page} />
</ScrollArea>
</Stack>
);
}
export function MobileFlightsList() {
const [aircraft, setAircraft] = useState("");
const client = useApi();
const flights = useQuery({
queryKey: ["flights-list", aircraft],
queryFn: async () =>
await client
.get(
`/flights/by-date?order=1${
aircraft !== "" ? `&filter=aircraft&filter_val=${aircraft}` : ""
}`
)
.then((res) => res.data),
});
const navigate = useNavigate();
const scrollAreaRef = useRef(null);
return (
<Stack p="0" m="0" justify="space-between" h="calc(100vh - 95px)">
<ScrollArea h="calc(100vh - 95px - 50px" ref={scrollAreaRef}>
<FlightsListDisplay flights={flights} />
</ScrollArea>{" "}
<Group grow preventGrowOverflow={false} wrap="nowrap">
<AircraftFilter aircraft={aircraft} setAircraft={setAircraft} />
<Button
variant="outline"
leftSection={<IconPlus />}
onClick={() => navigate("/logbook/flights/new")}
>
Add
</Button>
</Group>
</Stack>
);
}
export default { FlightsList, MobileFlightsList };

View File

@ -0,0 +1,69 @@
import { Container, Stack, Title } from "@mantine/core";
import { FlightFormSchema, flightCreateHelper } from "@/util/types";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useApi } from "@/util/api";
import { useNavigate } from "@remix-run/react";
import FlightForm from "@/ui/form/flight-form";
export default function NewFlight() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const client = useApi();
const createFlight = useMutation({
mutationFn: async (values: FlightFormSchema) => {
const newFlight = flightCreateHelper(values);
if (newFlight) {
const res = await client.post("/flights", newFlight);
const id = res.data.id;
if (!id) throw new Error("Flight creation failed");
const imageForm = new FormData();
// Upload images
if (values.images.length > 0) {
for (const img of values.images) {
imageForm.append("images", img);
}
const img_id = await client.post(
`/flights/${id}/add_images`,
imageForm,
{ headers: { "Content-Type": "multipart/form-data" } }
);
if (!img_id) {
await queryClient.invalidateQueries({ queryKey: ["flights-list"] });
throw new Error("Image upload failed");
}
}
return res.data;
}
throw new Error("Flight creation failed");
},
onSuccess: async (data: { id: string }) => {
await queryClient.invalidateQueries({ queryKey: ["flights-list"] });
navigate(`/logbook/flights/${data.id}`);
},
});
return (
<Container>
<Stack>
<Title order={2}>New Flight</Title>
<FlightForm
onSubmit={createFlight.mutate}
isPending={createFlight.isPending}
isError={createFlight.isError}
error={createFlight.error}
mah="calc(100vh - 95px - 110px)"
autofillHobbs
/>
</Stack>
</Container>
);
}

View File

@ -0,0 +1,24 @@
import { Divider, Grid, Container, ScrollArea } from "@mantine/core";
import { Outlet } from "@remix-run/react";
import { FlightsList } from "./flights-list";
export default function FlightsLayout() {
return (
<>
<Grid h="100%" visibleFrom="lg">
<Grid.Col span={4}>
<FlightsList />
</Grid.Col>
<Divider orientation="vertical" m="sm" />
<Grid.Col span="auto">
<ScrollArea.Autosize mah="calc(100vh - 95px)">
<Outlet />
</ScrollArea.Autosize>
</Grid.Col>
</Grid>
<Container hiddenFrom="lg" style={{ paddingLeft: 0, paddingRight: 0 }}>
<Outlet />
</Container>
</>
);
}

View File

@ -0,0 +1,134 @@
import ErrorDisplay from "@/ui/error-display";
import { useApi } from "@/util/api";
import {
Avatar,
Button,
Center,
Container,
Fieldset,
Group,
Loader,
PasswordInput,
Stack,
Text,
Title,
} from "@mantine/core";
import { useForm } from "@mantine/form";
import { IconFingerprint } from "@tabler/icons-react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { AxiosError } from "axios";
export default function Me() {
const client = useApi();
const user = useQuery({
queryKey: ["user"],
queryFn: async () => await client.get(`users/me`).then((res) => res.data),
});
const updatePassword = useMutation({
mutationFn: async (values: {
current_psk: string;
new_psk: string;
confirm_new_psk: string;
}) => {
await client.put(`/users/me/password`, {
current_password: values.current_psk,
new_password: values.new_psk,
});
},
});
const updatePskForm = useForm({
initialValues: {
current_psk: "",
new_psk: "",
confirm_new_psk: "",
},
validate: {
current_psk: (value) =>
value.length === 0 ? "Please enter your current password" : null,
new_psk: (value) => {
if (value.length === 0) return "Please enter a new password";
if (value.length < 8 || value.length > 16)
return "Password must be between 8 and 16 characters";
},
confirm_new_psk: (value, values) => {
if (value.length === 0) return "Please confirm your new password";
if (value.length < 8 || value.length > 16)
return "Password must be between 8 and 16 characters";
if (value !== values.new_psk) return "Passwords must match";
},
},
});
return (
<Container>
{user.isLoading ? (
<Center h="calc(100vh - 95px)">
<Loader />
</Center>
) : user.isError ? (
<Center h="calc(100vh - 95px)">
<ErrorDisplay error="Error Loading User" />
</Center>
) : user.data ? (
<Stack pt="xl">
<Stack align="center" pb="xl">
<Avatar size="xl" />
<Title order={2}>{user.data.username}</Title>
<Text>
{user.data.level === 2
? "Admin"
: user.data.level === 1
? "User"
: "Guest"}
</Text>{" "}
</Stack>
<form
onSubmit={updatePskForm.onSubmit((values) => {
updatePassword.mutate(values);
})}
>
<Fieldset legend="Update Password">
<PasswordInput
label="Current Password"
{...updatePskForm.getInputProps("current_psk")}
/>
<PasswordInput
mt="sm"
label="New Password"
{...updatePskForm.getInputProps("new_psk")}
/>
<PasswordInput
mt="sm"
label="Confirm New Password"
{...updatePskForm.getInputProps("confirm_new_psk")}
/>
<Group justify="flex-end" mt="lg">
{updatePassword.isPending ? (
<Text>Updating...</Text>
) : updatePassword.isError ? (
updatePassword.error &&
(updatePassword.error as AxiosError).response?.status ===
403 ? (
<Text c="red">Incorrect password</Text>
) : (
<Text c="red">Failed: {updatePassword.error.message}</Text>
)
) : updatePassword.isSuccess ? (
<Text c="green">Updated</Text>
) : null}
<Button type="submit" leftSection={<IconFingerprint />}>
Update
</Button>
</Group>
</Fieldset>
</form>
</Stack>
) : (
<Text c="red">Unknown Error</Text>
)}
</Container>
);
}

View File

@ -0,0 +1,70 @@
import { TailfinAppShell } from "@/ui/nav/app-shell";
import { useAuth } from "@/util/auth";
import type { MetaFunction } from "@remix-run/node";
import { Outlet, useLocation, useNavigate } from "@remix-run/react";
import {
QueryCache,
QueryClient,
QueryClientProvider,
} from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { AxiosError } from "axios";
import { useEffect, useState } from "react";
export const meta: MetaFunction = () => {
return [
{ title: "Tailfin" },
{ name: "description", content: "Self-hosted flight logbook" },
];
};
export default function Index() {
const { user, loading } = useAuth();
const navigate = useNavigate();
const location = useLocation();
useEffect(() => {
if (!loading && !user) {
navigate("/login");
} else if (location.pathname === "/logbook") {
navigate("/logbook/dashboard");
}
}, [user, loading, navigate, location]);
const [queryClient] = useState(
() =>
new QueryClient({
queryCache: new QueryCache({
onError: (error: Error) => {
if (error instanceof AxiosError && error.response?.status === 401) {
navigate("/login");
}
},
}),
defaultOptions: {
queries: {
staleTime: 1000,
retry: (failureCount, error: Error) => {
return (
failureCount < 3 &&
(!error ||
(error instanceof AxiosError &&
error.response?.status !== 401 &&
error.response?.status !== 404))
);
},
},
},
})
);
return (
<QueryClientProvider client={queryClient}>
<TailfinAppShell>
<Outlet />
</TailfinAppShell>
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}

107
web/app/routes/login.tsx Normal file
View File

@ -0,0 +1,107 @@
import { useAuth } from "@/util/auth";
import {
Button,
Center,
Container,
Fieldset,
Group,
Image,
PasswordInput,
Space,
Stack,
Text,
TextInput,
Title,
} from "@mantine/core";
import { useForm } from "@mantine/form";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useEffect, useState } from "react";
function LoginPage() {
const [error, setError] = useState("");
const form = useForm({
initialValues: {
username: "",
password: "",
},
validate: {
username: (value) =>
value.length === 0 ? "Please enter a username" : null,
password: (value) =>
value.length === 0 ? "Please enter a password" : null,
},
});
const { signin } = useAuth();
useEffect(() => {
document.title = "Log In - Tailfin";
});
return (
<Container h="75%">
<Stack gap="md" h="100%" justify="center" align="stretch">
<Center>
<Image src="/logo.png" w="100px" />
</Center>
<Title order={2} style={{ textAlign: "center" }}>
Tailfin
</Title>
<Center>
<Fieldset legend="Log In" w="350px">
<form
onSubmit={form.onSubmit(async (values) => {
setError("");
try {
await signin(values)
.then(() => {})
.catch((err) => {
console.log(err);
setError((err as Error).message);
});
} catch (err) {
console.log(err);
setError((err as Error).message);
}
})}
>
<TextInput
label="Username"
{...form.getInputProps("username")}
mt="md"
/>
<PasswordInput
label="Password"
{...form.getInputProps("password")}
mt="md"
/>
{error === "" ? (
<Space mt="md" />
) : (
<Text mt="md" c="red">
{error}
</Text>
)}
<Group justify="center">
<Button type="submit" mt="xl" fullWidth>
Log In
</Button>
</Group>
</form>
</Fieldset>
</Center>
</Stack>
</Container>
);
}
export default function Login() {
const queryClient = new QueryClient();
return (
<QueryClientProvider client={queryClient}>
<LoginPage />
</QueryClientProvider>
);
}

View File

@ -0,0 +1,35 @@
import { ActionIcon, Collapse, Fieldset, Group, Text } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { IconMinus, IconPlus } from "@tabler/icons-react";
import { ReactNode } from "react";
export default function CollapsibleFieldset({
children,
legend,
w = "",
mt = "",
}: {
children: ReactNode;
legend?: string;
w?: string;
mt?: string;
}) {
const [open, { toggle }] = useDisclosure(true);
return (
<Fieldset
legend={
<Group gap="xs">
{legend ? <Text>{legend}</Text> : null}
<ActionIcon variant="transparent" onClick={toggle} color="gray">
{open ? <IconMinus /> : <IconPlus />}
</ActionIcon>
</Group>
}
w={w}
mt={mt}
>
<Collapse in={open}>{children}</Collapse>
</Fieldset>
);
}

View File

@ -0,0 +1,170 @@
import AircraftForm from "@/ui/form/aircraft-form";
import { useApi } from "@/util/api";
import { useAircraft, usePatchFlight } from "@/util/hooks";
import { AircraftFormSchema, AircraftSchema } from "@/util/types";
import {
ActionIcon,
Group,
Tooltip,
Text,
Select,
Modal,
Card,
Stack,
Button,
Loader,
UnstyledButton,
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { IconPlus, IconPencil, IconX } from "@tabler/icons-react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
export function AircraftLogItem({
label,
content,
id = "",
field = "",
}: {
label: string;
content: string | null;
id?: string;
field?: string;
}) {
const [editValue, setEditValue] = useState<string>(content ?? "");
const [editError, setEditError] = useState("");
const [editOpened, { open: openEdit, close: closeEdit }] =
useDisclosure(false);
const [aircraftOpened, { open: openAircraft, close: closeAircraft }] =
useDisclosure(false);
const client = useApi();
const queryClient = useQueryClient();
const addAircraft = useMutation({
mutationFn: async (values: AircraftFormSchema) => {
const newAircraft = values;
if (newAircraft) {
const res = await client.post("/aircraft", newAircraft);
return res.data;
}
throw new Error("Aircraft creation failed");
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["aircraft-list"] });
close();
},
});
const getAircraft = useAircraft();
const updateValue = usePatchFlight(id, field, closeEdit);
if (content === null) content = "";
const editForm = (
<Select
label={
<Group gap="0">
<Text size="sm" fw={700} span>
Aircraft
</Text>
<Tooltip label="Add Aircraft">
<ActionIcon variant="transparent" onClick={openAircraft}>
<IconPlus size="1rem" />
</ActionIcon>
</Tooltip>
</Group>
}
data={
getAircraft.isFetched
? getAircraft.data?.map((item: AircraftSchema) => ({
value: item.tail_no,
label: item.tail_no,
}))
: content
? [
{
value: content,
label: content,
},
]
: null
}
allowDeselect={false}
value={editValue}
onChange={(_value, option) => {
setEditError("");
setEditValue(option.label);
}}
error={editError}
/>
);
return (
<>
<Modal
opened={aircraftOpened}
onClose={closeAircraft}
title="New Aircraft"
centered
>
<AircraftForm
onSubmit={addAircraft.mutate}
isError={addAircraft.isError}
error={addAircraft.error}
isPending={addAircraft.isPending}
submitButtonLabel="Add"
withCancelButton
cancelFunc={closeAircraft}
/>
</Modal>
<Modal
opened={editOpened}
onClose={closeEdit}
title={`Edit ${label}`}
centered
>
<Stack>
{editForm}
<Group justify="flex-end">
{updateValue.isPending ? <Loader /> : null}
{updateValue.isError ? (
<Text c="red">{updateValue.error?.message}</Text>
) : null}
<Button
onClick={() => {
if (editValue.length === 0) {
setEditError("Please select an aircraft");
} else {
updateValue.mutate(editValue);
}
}}
leftSection={<IconPencil />}
>
Update
</Button>
</Group>
</Stack>
</Modal>
<Card shadow="sm" withBorder h="100%">
<Stack gap="xs" align="center" h="100%">
<Text c="dimmed">{label}</Text>
<Tooltip label={`Edit ${label}`}>
<UnstyledButton onClick={openEdit}>
<Text
size="lg"
style={{ textAlign: "center" }}
c={content === "" ? "dimmed" : ""}
>
{content === "" ? <IconX /> : content}
</Text>
</UnstyledButton>
</Tooltip>
</Stack>
</Card>
</>
);
}

View File

@ -0,0 +1,105 @@
import { usePatchFlight } from "@/util/hooks";
import {
Button,
Card,
Group,
Loader,
Modal,
Stack,
Text,
Tooltip,
UnstyledButton,
} from "@mantine/core";
import { DatePickerInput } from "@mantine/dates";
import { useDisclosure } from "@mantine/hooks";
import { IconPencil, IconX } from "@tabler/icons-react";
import { useState } from "react";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc.js";
dayjs.extend(utc);
export function DateLogItem({
label,
content,
id = "",
field = "",
}: {
label: string;
content: Date | string | null;
id?: string;
field?: string;
}) {
const [editValue, setEditValue] = useState<Date | null>(
content ? new Date(content as string) : null
);
const [editError, setEditError] = useState("");
const [editOpened, { open: openEdit, close: closeEdit }] =
useDisclosure(false);
const updateValue = usePatchFlight(id, field, closeEdit);
content = (content as string).split("T")[0];
const editForm = (
<DatePickerInput
label={label}
value={editValue}
onChange={setEditValue}
error={editError}
/>
);
return (
<>
<Modal
opened={editOpened}
onClose={closeEdit}
title={`Edit ${label}`}
centered
>
<Stack>
{editForm}
<Group justify="flex-end">
{updateValue.isPending ? <Loader /> : null}
{updateValue.isError ? (
<Text c="red">{updateValue.error?.message}</Text>
) : null}
<Button
onClick={() => {
if (editValue === null) {
setEditError("Please select a date");
} else {
updateValue.mutate(
dayjs(editValue).utc().startOf("day").toISOString()
);
}
}}
leftSection={<IconPencil />}
>
Update
</Button>
</Group>
</Stack>
</Modal>
<Card shadow="sm" withBorder h="100%">
<Stack gap="xs" align="center" h="100%">
<Text c="dimmed" style={{ textalign: "center" }}>
{label}
</Text>
<Tooltip label={`Edit ${label}`}>
<UnstyledButton onClick={openEdit}>
<Text
size="lg"
style={{ textAlign: "center" }}
c={content === "" ? "dimmed" : ""}
>
{content === "" ? <IconX /> : content}
</Text>
</UnstyledButton>
</Tooltip>
</Stack>
</Card>
</>
);
}

View File

@ -0,0 +1,99 @@
import {
Button,
Card,
Group,
Loader,
Modal,
Stack,
Text,
Tooltip,
UnstyledButton,
} from "@mantine/core";
import { IconPencil, IconX } from "@tabler/icons-react";
import { useState } from "react";
import { ZeroHourInput } from "@/ui/input/hour-input";
import { useDisclosure } from "@mantine/hooks";
import { usePatchFlight } from "@/util/hooks";
export function HourLogItem({
label,
content,
id = "",
field = "",
}: {
label: string;
content: number | string | null;
id?: string;
field?: string;
}) {
content = Number(content);
const [editValue, setEditValue] = useState<number | null>(content);
const [editError, setEditError] = useState("");
const [editOpened, { open: openEdit, close: closeEdit }] =
useDisclosure(false);
const updateValue = usePatchFlight(id, field, closeEdit);
const editForm = (
<ZeroHourInput
label=""
value={editValue}
setValue={setEditValue}
error={editError}
/>
);
return (
<>
<Modal
opened={editOpened}
onClose={closeEdit}
title={`Edit ${label}`}
centered
>
<Stack>
{editForm}
<Group justify="flex-end">
{updateValue.isPending ? <Loader /> : null}
{updateValue.isError ? (
<Text c="red">{updateValue.error?.message}</Text>
) : null}
<Button
onClick={() => {
if (editValue === null || editValue < 0) {
setEditError("Please enter a valid hour number");
} else {
updateValue.mutate(editValue);
}
}}
leftSection={<IconPencil />}
>
Update
</Button>
</Group>
</Stack>
</Modal>
<Card shadow="sm" withBorder h="100%">
<Stack gap="xs" align="center" h="100%">
<Text c="dimmed" style={{ textalign: "center" }}>
{label}
</Text>
<Tooltip label={`Edit ${label}`}>
<UnstyledButton onClick={openEdit}>
<Text
size="lg"
style={{ textAlign: "center" }}
c={content === null ? "dimmed" : ""}
>
{content === null ? <IconX /> : content}
</Text>
</UnstyledButton>
</Tooltip>
</Stack>
</Card>
</>
);
}

View File

@ -0,0 +1,28 @@
.control {
&[data-inactive] {
opacity: 0;
cursor: default;
}
}
.controls {
transition: opacity 150ms ease;
opacity: 0;
}
.root {
&:hover {
.controls {
opacity: 1;
}
}
}
.indicator {
width: rem(12px);
height: rem(4px);
transition: width 250ms ease;
&[data-active] {
width: rem(40px);
}
}

View File

@ -0,0 +1,150 @@
import { Carousel } from "@mantine/carousel";
import classes from "./img-log-item.module.css";
import { randomId, useDisclosure } from "@mantine/hooks";
import SecureImage from "../secure-img";
import {
ActionIcon,
Button,
Group,
Loader,
Modal,
Stack,
Tooltip,
Text,
} from "@mantine/core";
import { IconPencil } from "@tabler/icons-react";
import ImageListInput from "@/ui/input/image-list-input";
import ImageUpload from "@/ui/input/image-upload";
import { useEffect, useState } from "react";
import { useApi } from "@/util/api";
import { useMutation, useQueryClient } from "@tanstack/react-query";
export default function ImageLogItem({
imageIds,
id,
mah = "",
}: {
imageIds: string[];
id: string;
mah?: string;
}) {
const [editOpened, { open: openEdit, close: closeEdit }] =
useDisclosure(false);
const [existingImages, setExistingImages] = useState<string[]>(imageIds);
const [newImages, setNewImages] = useState<File[]>([]);
const client = useApi();
const queryClient = useQueryClient();
const updateValue = useMutation({
mutationFn: async () => {
const missing = imageIds.filter(
(item: string) => existingImages?.indexOf(item) < 0
);
for (const img of missing) {
await client.delete(`/img/${img}`);
}
await client
.patch(`/flights/${id}`, { images: existingImages })
.then((res) => res.data);
// Upload images
if (newImages.length > 0) {
const imageForm = new FormData();
for (const img of newImages ?? []) {
imageForm.append("images", img);
}
const img_id = await client.post(
`/flights/${id}/add_images`,
imageForm,
{ headers: { "Content-Type": "multipart/form-data" } }
);
if (!img_id) {
await queryClient.invalidateQueries({ queryKey: [id] });
await queryClient.invalidateQueries({ queryKey: ["flights-list"] });
throw new Error("Image upload failed");
}
}
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: [id] });
await queryClient.invalidateQueries({ queryKey: ["flights-list"] });
closeEdit();
},
});
useEffect(() => {
setExistingImages(imageIds);
}, [imageIds]);
return (
<>
<Modal
opened={editOpened}
onClose={closeEdit}
title={`Edit Images`}
centered
>
<Stack>
<ImageUpload
value={newImages}
setValue={setNewImages}
label="Add Images"
mt="md"
placeholder="Images"
/>
<ImageListInput
label="Existing Images"
imageIds={existingImages}
setImageIds={setExistingImages}
collapsible
startCollapsed
/>
<Group justify="flex-end">
{updateValue.isPending ? <Loader /> : null}
{updateValue.isError ? (
<Text c="red">{updateValue.error?.message}</Text>
) : null}
<Button
onClick={() => {
updateValue.mutate();
}}
leftSection={<IconPencil />}
>
Update
</Button>
</Group>
</Stack>
</Modal>
<Stack>
<Group justify="flex-end" py="0" my="0">
<Tooltip label="Edit Images">
<ActionIcon variant="transparent" onClick={openEdit}>
<IconPencil />
</ActionIcon>
</Tooltip>
</Group>
<Carousel
style={{ maxHeight: mah }}
withIndicators
slideGap="sm"
slideSize={{ base: "100%", sm: "80%" }}
classNames={classes}
>
{imageIds.map((img) => (
<Carousel.Slide key={randomId()}>
<SecureImage key={randomId()} id={img} h="700px" radius="lg" />
</Carousel.Slide>
))}
</Carousel>
</Stack>
</>
);
}

View File

@ -0,0 +1,99 @@
import {
Button,
Card,
Group,
Loader,
Modal,
Stack,
Text,
Tooltip,
UnstyledButton,
} from "@mantine/core";
import { IconPencil, IconX } from "@tabler/icons-react";
import { useState } from "react";
import { useDisclosure } from "@mantine/hooks";
import { usePatchFlight } from "@/util/hooks";
import { ZeroIntInput } from "@/ui/input/int-input";
export function IntLogItem({
label,
content,
id = "",
field = "",
}: {
label: string;
content: number | string | null;
id?: string;
field?: string;
}) {
content = Number(content);
const [editValue, setEditValue] = useState<number>(content);
const [editError, setEditError] = useState("");
const [editOpened, { open: openEdit, close: closeEdit }] =
useDisclosure(false);
const updateValue = usePatchFlight(id, field, closeEdit);
const editForm = (
<ZeroIntInput
label=""
value={editValue}
setValue={setEditValue}
error={editError}
/>
);
return (
<>
<Modal
opened={editOpened}
onClose={closeEdit}
title={`Edit ${label}`}
centered
>
<Stack>
{editForm}
<Group justify="flex-end">
{updateValue.isPending ? <Loader /> : null}
{updateValue.isError ? (
<Text c="red">{updateValue.error?.message}</Text>
) : null}
<Button
onClick={() => {
if (editValue === null || editValue < 0) {
setEditError("Please enter a valid number");
} else {
updateValue.mutate(editValue);
}
}}
leftSection={<IconPencil />}
>
Update
</Button>
</Group>
</Stack>
</Modal>
<Card shadow="sm" withBorder h="100%">
<Stack gap="xs" align="center" h="100%">
<Text c="dimmed" style={{ textalign: "center" }}>
{label}
</Text>
<Tooltip label={`Edit ${label}`}>
<UnstyledButton onClick={openEdit}>
<Text
size="lg"
style={{ textAlign: "center" }}
c={content === null ? "dimmed" : ""}
>
{content === null ? <IconX /> : content}
</Text>
</UnstyledButton>
</Tooltip>
</Stack>
</Card>
</>
);
}

View File

@ -0,0 +1,115 @@
import {
Badge,
Button,
Card,
Group,
Loader,
Modal,
Stack,
Text,
Tooltip,
UnstyledButton,
} from "@mantine/core";
import { randomId, useDisclosure } from "@mantine/hooks";
import { IconPencil, IconX } from "@tabler/icons-react";
import { useState } from "react";
import ListInput from "@/ui/input/list-input";
import { usePatchFlight } from "@/util/hooks";
export function LogItem({
label,
content,
}: {
label: string;
content: string | null;
}) {
if (content === null) content = "";
return (
<Group justify="space-between" px="sm">
<Text>{label}</Text>
<Text>{content}</Text>
</Group>
);
}
export function ListLogItem({
label,
content,
listColor = "",
id = "",
field = "",
}: {
label: string;
content: string | string[] | null;
listColor?: string;
id?: string;
field?: string;
}) {
if (content === null) content = [];
if (content instanceof String) content = [content as string];
const [editValue, setEditValue] = useState<string[]>(content as string[]);
const [editOpened, { open: openEdit, close: closeEdit }] =
useDisclosure(false);
const updateValue = usePatchFlight(id, field, closeEdit);
const editForm = (
<ListInput label={label} value={editValue} setValue={setEditValue} />
);
return (
<>
<Modal
opened={editOpened}
onClose={closeEdit}
title={`Edit ${label}`}
centered
>
<Stack>
{editForm}
<Group justify="flex-end">
{updateValue.isPending ? <Loader /> : null}
{updateValue.isError ? (
<Text c="red">{updateValue.error?.message}</Text>
) : null}
<Button
onClick={() => {
updateValue.mutate(editValue);
}}
leftSection={<IconPencil />}
>
Update
</Button>
</Group>
</Stack>
</Modal>
<Card shadow="sm" withBorder h="100%">
<Stack gap="xs" align="center" h="100%">
<Text c="dimmed" style={{ textalign: "center" }}>
{label}
</Text>
<Tooltip label={`Edit ${label}`}>
<UnstyledButton onClick={openEdit}>
{(content as string[]).length > 0 ? (
<Text size="lg">
{(content as string[]).map((item) => (
<Badge key={randomId()} size="lg" mx="xs" color={listColor}>
{item}
</Badge>
))}
</Text>
) : (
<Text size="lg" style={{ textAlign: "center" }} c="dimmed">
<IconX />
</Text>
)}
</UnstyledButton>
</Tooltip>
</Stack>
</Card>
</>
);
}

View File

@ -0,0 +1,88 @@
import {
Button,
Card,
Group,
Loader,
Modal,
Stack,
Text,
Textarea,
Tooltip,
UnstyledButton,
} from "@mantine/core";
import { IconPencil, IconX } from "@tabler/icons-react";
import { useState } from "react";
import { useDisclosure } from "@mantine/hooks";
import { usePatchFlight } from "@/util/hooks";
export function TextLogItem({
label,
content,
id = "",
field = "",
}: {
label: string;
content: string | null;
id?: string;
field?: string;
}) {
const [editValue, setEditValue] = useState<string>(content ?? "");
const [editOpened, { open: openEdit, close: closeEdit }] =
useDisclosure(false);
const updateValue = usePatchFlight(id, field, closeEdit);
const editForm = (
<Textarea
label=""
value={editValue}
onChange={(event) => setEditValue(event.currentTarget.value)}
/>
);
return (
<>
<Modal
opened={editOpened}
onClose={closeEdit}
title={`Edit ${label}`}
centered
>
<Stack>
{editForm}
<Group justify="flex-end">
{updateValue.isPending ? <Loader /> : null}
{updateValue.isError ? (
<Text c="red">{updateValue.error?.message}</Text>
) : null}
<Button
onClick={() => updateValue.mutate(editValue)}
leftSection={<IconPencil />}
>
Update
</Button>
</Group>
</Stack>
</Modal>
<Card shadow="sm" withBorder h="100%">
<Stack gap="xs" align="center" h="100%">
<Text c="dimmed" style={{ textalign: "center" }}>
{label}
</Text>
<Tooltip label={`Edit ${label}`}>
<UnstyledButton onClick={openEdit}>
<Text
size="lg"
style={{ textAlign: "center" }}
c={content === null ? "dimmed" : ""}
>
{content === null ? <IconX /> : content}
</Text>
</UnstyledButton>
</Tooltip>
</Stack>
</Card>
</>
);
}

View File

@ -0,0 +1,120 @@
import {
Button,
Card,
Group,
Loader,
Modal,
Stack,
Text,
Tooltip,
UnstyledButton,
} from "@mantine/core";
import { IconPencil, IconX } from "@tabler/icons-react";
import { useState } from "react";
import TimeInput from "@/ui/input/time-input";
import { useDisclosure } from "@mantine/hooks";
import dayjs from "dayjs";
import { usePatchFlight } from "@/util/hooks";
import utc from "dayjs/plugin/utc.js";
dayjs.extend(utc);
export function TimeLogItem({
label,
content,
date,
id = "",
field = "",
}: {
label: string;
content: string | null;
date: dayjs.Dayjs | string;
id?: string;
field?: string;
}) {
if (date instanceof String) date = dayjs(date);
const time = (content as string).split("T")[1].split(":");
const [editValue, setEditValue] = useState<number | string | undefined>(
Number(`${time[0]}${time[1]}`)
);
const [editError, setEditError] = useState("");
const [editOpened, { open: openEdit, close: closeEdit }] =
useDisclosure(false);
const updateValue = usePatchFlight(id, field, closeEdit);
content = `${time[0]}:${time[1]}`;
const editForm = (
<TimeInput
label={label}
value={editValue}
setValue={setEditValue}
error={editError}
allowLeadingZeros
/>
);
return (
<>
<Modal
opened={editOpened}
onClose={closeEdit}
title={`Edit ${label}`}
centered
>
<Stack>
{editForm}
<Group justify="flex-end">
{updateValue.isPending ? <Loader /> : null}
{updateValue.isError ? (
<Text c="red">{updateValue.error?.message}</Text>
) : null}
<Button
onClick={() => {
if (Number(editValue) > 2359)
setEditError("Time must be between 0000 and 2359");
else if (Number(editValue) % 100 > 59)
setEditError("Minutes must not exceed 59");
else {
updateValue.mutate(
dayjs(date)
.utc()
.hour(Math.floor((Number(editValue) ?? 0) / 100))
.minute(Math.floor((Number(editValue) ?? 0) % 100))
.second(0)
.millisecond(0)
.toISOString()
);
}
}}
leftSection={<IconPencil />}
>
Update
</Button>
</Group>
</Stack>
</Modal>
<Card shadow="sm" withBorder h="100%">
<Stack gap="xs" align="center" h="100%">
<Text c="dimmed" style={{ textalign: "center" }}>
{label}
</Text>
<Tooltip label={`Edit ${label}`}>
<UnstyledButton onClick={openEdit}>
<Text
size="lg"
style={{ textAlign: "center" }}
c={content === null ? "dimmed" : ""}
>
{content === null ? <IconX /> : content}
</Text>
</UnstyledButton>
</Tooltip>
</Stack>
</Card>
</>
);
}

View File

@ -0,0 +1,84 @@
import { Badge, Card, Group, Stack, Text } from "@mantine/core";
import { randomId } from "@mantine/hooks";
import { IconX } from "@tabler/icons-react";
export function LogItem({
label,
content,
}: {
label: string;
content: string | null;
}) {
if (content === null) content = "";
return (
<Group justify="space-between" px="sm">
<Text>{label}</Text>
<Text>{content}</Text>
</Group>
);
}
export function VerticalLogItem({
label,
content,
decimal = 0,
hours = false,
time = false,
date = false,
list = false,
listColor = "",
}: {
label: string;
content: string | string[] | null;
decimal?: number;
hours?: boolean;
time?: boolean;
date?: boolean;
list?: boolean;
listColor?: string;
}) {
if (content === null) content = "";
if (decimal > 0) content = Number(content).toFixed(decimal);
if (hours) content = Number(content).toFixed(1);
if (time) {
const time = (content as string).split("T")[1].split(":");
content = `${time[0]}:${time[1]}`;
}
if (date) content = (content as string).split("T")[0];
return (
<Card shadow="sm" withBorder h="100%">
<Stack gap="xs" align="center" h="100%">
<Text c="dimmed" style={{ textalign: "center" }}>
{label}
</Text>
{list ? (
<>
{(content as string[]).length > 0 ? (
<Text size="lg">
{(content as string[]).map((item) => (
<Badge key={randomId()} size="lg" mx="xs" color={listColor}>
{item}
</Badge>
))}
</Text>
) : (
<Text size="lg" style={{ textAlign: "center" }} c="dimmed">
<IconX />
</Text>
)}
</>
) : (
<Text
size="lg"
style={{ textAlign: "center" }}
c={content === "" ? "dimmed" : ""}
>
{content === "" ? <IconX /> : content}
</Text>
)}
</Stack>
</Card>
);
}

View File

@ -0,0 +1,99 @@
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",
h = "",
clickable = true,
}: {
id: string;
radius?: string;
h?: string;
clickable?: boolean;
}) {
const { isLoading, error, data } = useFetchImageAsBase64(id);
const [opened, { open, close }] = useDisclosure(false);
if (isLoading)
return (
<Center h="500px">
<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}
w="auto"
maw="100%"
h="100%"
m="auto"
fit="contain"
style={{ maxHeight: h ?? "" }}
onClick={() => {
if (clickable) {
open();
}
}}
/>
</>
);
}

View File

@ -0,0 +1,13 @@
import { Stack, Text } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
export default function ErrorDisplay({ error }: { error: string }) {
return (
<Stack align="center" justify="center" h="100%" m="0" p="0">
<Text c="red">
<IconAlertTriangle size="3rem" />
</Text>
<Text c="red">{error}</Text>
</Stack>
);
}

View File

@ -0,0 +1,186 @@
import { useApi } from "@/util/api";
import { AircraftFormSchema } from "@/util/types";
import {
Button,
Container,
Group,
NumberInput,
Select,
Stack,
Text,
TextInput,
} from "@mantine/core";
import { useForm } from "@mantine/form";
import { IconPencil, IconX } from "@tabler/icons-react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { AxiosError } from "axios";
import { useState } from "react";
export default function AircraftForm({
onSubmit,
isError,
error,
isPending,
initialValues,
submitButtonLabel,
withCancelButton,
cancelFunc,
}: {
onSubmit: (values: AircraftFormSchema) => void;
isError: boolean;
error: Error | null;
isPending: boolean;
initialValues?: AircraftFormSchema | null;
mah?: string;
submitButtonLabel?: string;
withCancelButton?: boolean;
cancelFunc?: () => void;
}) {
const newForm = useForm<AircraftFormSchema>({
initialValues: initialValues ?? {
tail_no: "",
make: "",
model: "",
aircraft_category: "",
aircraft_class: "",
hobbs: 0.0,
},
validate: {
tail_no: (value) =>
value === null || value.trim() === ""
? "Please enter a tail number"
: null,
make: (value) =>
value === null || value.trim() === "" ? "Please enter a make" : null,
model: (value) =>
value === null || value.trim() === "" ? "Please enter a model" : null,
aircraft_category: (value) =>
value === null || value.trim() === ""
? "Please select a category"
: null,
aircraft_class: (value) =>
value === null || value.trim() === "" ? "Please select a class" : null,
},
});
const client = useApi();
const queryClient = useQueryClient();
const categories = useQuery({
queryKey: ["categories"],
queryFn: async () =>
await client.get(`/aircraft/categories`).then((res) => res.data),
});
const [category, setCategory] = useState(
initialValues?.aircraft_category ?? ""
);
const [classSelection, setClassSelection] = useState<string | null>(
initialValues?.aircraft_class ?? ""
);
const classes = useQuery({
queryKey: ["classes", category],
queryFn: async () =>
await client
.get(`/aircraft/class?category=${category}`)
.then((res) => res.data),
enabled: !!category,
});
return (
<form onSubmit={newForm.onSubmit((values) => onSubmit(values))}>
<Container>
<Stack>
<TextInput
label="Tail Number"
withAsterisk
{...newForm.getInputProps("tail_no")}
/>
<TextInput
label="Make"
{...newForm.getInputProps("make")}
withAsterisk
/>
<TextInput
label="Model"
{...newForm.getInputProps("model")}
withAsterisk
/>
<Select
{...newForm.getInputProps("aircraft_category")}
label="Category"
placeholder="Pick a value"
withAsterisk
data={
categories.isFetched && !categories.isError
? categories.data.categories
: []
}
onChange={(_value, option) => {
newForm.setFieldValue("aircraft_category", option.value);
setCategory(option.value);
newForm.setFieldValue("aircraft_class", "");
setClassSelection(null);
queryClient.invalidateQueries({
queryKey: ["classes", option.value],
});
}}
key={classSelection}
/>
<Select
{...newForm.getInputProps("aircraft_class")}
label="Class"
placeholder="Pick a value"
withAsterisk
data={
classes.isFetched && !classes.isError && classes.data
? classes.data.classes
: []
}
value={classSelection}
onChange={(_value, option) => {
newForm.setFieldValue("aircraft_class", option.label);
setClassSelection(option.label);
}}
/>
<NumberInput
label="Hobbs"
min={0.0}
suffix=" hrs"
decimalScale={1}
fixedDecimalScale
{...newForm.getInputProps("hobbs")}
/>
<Group justify="flex-end">
{isError ? (
<Text c="red">
{error instanceof AxiosError
? error?.response?.data?.detail ??
error?.response?.data ??
error?.response ??
error?.message ??
"Failed to submit"
: error?.message}
</Text>
) : isPending ? (
<Text c="yellow">Loading...</Text>
) : null}
{withCancelButton ? (
<Button leftSection={<IconX />} color="gray" onClick={cancelFunc}>
Cancel
</Button>
) : null}
<Button
type="submit"
leftSection={<IconPencil />}
onClick={() => null}
>
{submitButtonLabel ?? "Submit"}
</Button>
</Group>
</Stack>
</Container>
</form>
);
}

View File

@ -0,0 +1,556 @@
import {
AircraftFormSchema,
AircraftSchema,
FlightFormSchema,
} from "@/util/types";
import {
ActionIcon,
Button,
CloseButton,
Container,
Fieldset,
Group,
Loader,
Modal,
NumberInput,
ScrollArea,
Select,
Text,
TextInput,
Textarea,
Tooltip,
} from "@mantine/core";
import { DatePickerInput } from "@mantine/dates";
import { useForm } from "@mantine/form";
import dayjs from "dayjs";
import { HourInput, ZeroHourInput } from "./hour-input";
import TimeInput from "./time-input";
import { ZeroIntInput } from "./int-input";
import ListInput from "./list-input";
import { IconPencil, IconPlaneTilt, IconPlus } from "@tabler/icons-react";
import { useDisclosure } from "@mantine/hooks";
import AircraftForm from "./aircraft-form";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useApi } from "@/util/api";
import { useAircraft } from "@/util/hooks";
import { useEffect, useState } from "react";
import ImageUpload from "./image-upload";
import ImageListInput from "./image-list-input";
export default function FlightForm({
onSubmit,
isPending,
isError,
error,
initialValues,
mah,
submitButtonLabel,
withCancelButton,
cancelFunc,
autofillHobbs = false,
}: {
onSubmit: (values: FlightFormSchema) => void;
isPending: boolean;
isError: boolean;
error: Error | null;
initialValues?: FlightFormSchema | null;
mah?: string;
submitButtonLabel?: string;
withCancelButton?: boolean;
cancelFunc?: () => void;
autofillHobbs?: boolean;
}) {
const validate_time = (value: number | null) => {
if (value === null) return;
if (value > 2359) return "Time must be between 0000 and 2359";
if (value % 100 > 59) return "Minutes must not exceed 59";
};
const form = useForm<FlightFormSchema>({
initialValues: initialValues ?? {
date: dayjs(),
aircraft: "",
waypoint_from: "",
waypoint_to: "",
route: "",
hobbs_start: null,
hobbs_end: null,
time_start: null,
time_off: null,
time_down: null,
time_stop: null,
time_total: 0.0,
time_pic: 0.0,
time_sic: 0.0,
time_night: 0.0,
time_solo: 0.0,
time_xc: 0.0,
dist_xc: 0.0,
landings_day: 0,
landings_night: 0,
time_instrument: 0.0,
time_sim_instrument: 0.0,
holds_instrument: 0,
dual_given: 0.0,
dual_recvd: 0.0,
time_sim: 0.0,
time_ground: 0.0,
tags: [],
pax: [],
crew: [],
comments: "",
existing_images: [],
images: [],
},
validate: {
aircraft: (value) =>
value?.length ?? 0 > 0 ? null : "Please select an aircraft",
time_start: (value) => validate_time(value),
time_off: (value) => validate_time(value),
time_down: (value) => validate_time(value),
time_stop: (value) => validate_time(value),
},
});
const [aircraftOpened, { open: openAircraft, close: closeAircraft }] =
useDisclosure(false);
const client = useApi();
const queryClient = useQueryClient();
const addAircraft = useMutation({
mutationFn: async (values: AircraftFormSchema) => {
const newAircraft = values;
if (newAircraft) {
const res = await client.post("/aircraft", newAircraft);
return res.data;
}
throw new Error("Aircraft creation failed");
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["aircraft-list"] });
close();
},
});
const [aircraft, setAircraft] = useState<string | null>(
initialValues?.aircraft ?? ""
);
const [hobbsTouched, setHobbsTouched] = useState(false);
const getHobbs = useQuery({
queryKey: ["hobbs", aircraft],
queryFn: async () =>
await client.get(`/aircraft/tail/${aircraft}`).then((res) => res.data),
enabled: !!aircraft && aircraft !== "",
});
const getAircraft = useAircraft();
useEffect(() => {
if (autofillHobbs && getHobbs.isFetched && getHobbs.data && !hobbsTouched) {
form.setFieldValue(
"hobbs_start",
getHobbs.data.hobbs ?? form.getTransformedValues()["hobbs_start"]
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [getHobbs.data]);
return (
<>
<Modal
opened={aircraftOpened}
onClose={closeAircraft}
title="New Aircraft"
centered
>
<AircraftForm
onSubmit={addAircraft.mutate}
isError={addAircraft.isError}
error={addAircraft.error}
isPending={addAircraft.isPending}
submitButtonLabel="Add"
withCancelButton
cancelFunc={closeAircraft}
/>
</Modal>
<form onSubmit={form.onSubmit((values) => onSubmit(values))}>
<ScrollArea.Autosize mah={mah}>
<Container>
{/* Date and Aircraft */}
<Fieldset>
<Group justify="center" grow>
<DatePickerInput
label="Date"
{...form.getInputProps("date")}
withAsterisk
/>
<Select
label={
<Group gap="0">
<Text size="sm" fw={700} span>
Aircraft
</Text>
<Text
pl="0.3rem"
style={{ color: "var(--mantine-color-error)" }}
span
>
*
</Text>
<Tooltip label="Add Aircraft">
<ActionIcon
variant="transparent"
onClick={openAircraft}
>
<IconPlus size="1rem" />
</ActionIcon>
</Tooltip>
</Group>
}
data={
getAircraft.isFetched
? getAircraft.data?.map((item: AircraftSchema) => ({
value: item.tail_no,
label: item.tail_no,
}))
: initialValues
? [
{
value: initialValues?.aircraft,
label: initialValues?.aircraft,
},
]
: null
}
allowDeselect={false}
value={aircraft}
{...form.getInputProps("aircraft")}
onChange={(_value, option) => {
form.setFieldValue("aircraft", option.label);
setAircraft(option.label);
queryClient.invalidateQueries({
queryKey: ["hobbs", aircraft],
});
}}
/>
</Group>
</Fieldset>
{/* Route */}
<Fieldset legend="Route" mt="lg">
<Group justify="center" grow>
<TextInput
label="Waypoint From"
{...form.getInputProps("waypoint_from")}
/>
<TextInput
label="Waypoint To"
{...form.getInputProps("waypoint_to")}
/>
</Group>
<TextInput
label="Route"
{...form.getInputProps("route")}
mt="md"
/>
</Fieldset>
{/* Times */}
<Fieldset legend="Times" mt="md">
<Group justify="center" grow>
<NumberInput
label={
<Group gap="0">
<Text size="sm" fw={700} span>
Hobbs Start
</Text>
<Tooltip
label={
getHobbs.isFetched &&
getHobbs.data &&
getHobbs.data.hobbs ===
form.getTransformedValues()["hobbs_start"]
? "Using aircraft time"
: "Use Aircraft Time"
}
>
<ActionIcon
variant="transparent"
disabled={
!(
getHobbs.isFetched &&
getHobbs.data &&
getHobbs.data.hobbs !==
form.getTransformedValues()["hobbs_start"]
)
}
style={
!(
getHobbs.isFetched &&
getHobbs.data &&
getHobbs.data.hobbs !==
form.getTransformedValues()["hobbs_start"]
)
? { backgroundColor: "transparent" }
: {}
}
onClick={() =>
form.setFieldValue(
"hobbs_start",
getHobbs.data?.hobbs ?? 0.0
)
}
>
<IconPlaneTilt size="1rem" />
</ActionIcon>
</Tooltip>
</Group>
}
decimalScale={1}
step={0.1}
min={0}
fixedDecimalScale
leftSection={
<CloseButton
aria-label="Clear input"
onClick={() => form.setFieldValue("hobbs_start", "")}
style={{
display:
["", null].indexOf(
form.getTransformedValues()["hobbs_start"] as
| string
| null
) > -1
? "none"
: undefined,
}}
/>
}
{...form.getInputProps("hobbs_start")}
onChange={(e) => {
form.setFieldValue("hobbs_start", e);
setHobbsTouched(true);
}}
/>
<HourInput form={form} field="hobbs_end" label="Hobbs End" />
</Group>
</Fieldset>
{/* Start/Stop */}
<Fieldset legend="Start/Stop" mt="md">
<Group justify="center" grow>
<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"
allowLeadingZeros
/>
<TimeInput
form={form}
field="time_stop"
label="Stop Time"
allowLeadingZeros
/>
</Group>
</Fieldset>
{/* Hours */}
<Fieldset legend="Hours" mt="md">
<Group justify="center" grow>
<ZeroHourInput
form={form}
field="time_total"
label="Time Total"
/>
<ZeroHourInput form={form} field="time_pic" label="Time PIC" />
<ZeroHourInput form={form} field="time_sic" label="Time SIC" />
</Group>
<Group justify="center" grow mt="md">
<ZeroHourInput
form={form}
field="time_night"
label="Time Night"
/>
<ZeroHourInput
form={form}
field="time_solo"
label="Time Solo"
/>
</Group>
</Fieldset>
{/* Cross-Country */}
<Fieldset legend="Cross-Country" mt="md">
<Group justify="center" grow>
<ZeroHourInput form={form} field="time_xc" label="Hours" />
<NumberInput
label="Distance"
decimalScale={1}
min={0}
fixedDecimalScale
leftSection={
<CloseButton
aria-label="Clear input"
onClick={() => form.setFieldValue("dist_xc", 0)}
style={{
display:
form.getTransformedValues().dist_xc == 0
? "none"
: undefined,
}}
/>
}
{...form.getInputProps("dist_xc")}
/>
</Group>
</Fieldset>
{/* Landings */}
<Fieldset legend="Landings" mt="md">
<Group justify="center" grow>
<ZeroIntInput form={form} field="landings_day" label="Day" />
<ZeroIntInput
form={form}
field="landings_night"
label="Night"
/>
</Group>
</Fieldset>
{/* Instrument */}
<Fieldset legend="Instrument" mt="md">
<Group justify="center" grow>
<ZeroHourInput
form={form}
field="time_instrument"
label="Time Instrument"
/>
<ZeroHourInput
form={form}
field="time_sim_instrument"
label="Time Sim Instrument"
/>
<ZeroIntInput
form={form}
field="holds_instrument"
label="Instrument Holds"
/>
</Group>
</Fieldset>
{/* Instruction */}
<Fieldset legend="Instruction" mt="md">
<Group justify="center" grow>
<ZeroHourInput
form={form}
field="dual_given"
label="Dual Given"
/>
<ZeroHourInput
form={form}
field="dual_recvd"
label="Dual Received"
/>
<ZeroHourInput form={form} field="time_sim" label="Sim Time" />
<ZeroHourInput
form={form}
field="time_ground"
label="Ground Time"
/>
</Group>
</Fieldset>
{/* About the Flight */}
<Fieldset legend="About" mt="md">
<ListInput form={form} field="tags" label="Tags" />
<Group justify="center" grow mt="md">
<ListInput form={form} field="pax" label="Pax" />
<ListInput form={form} field="crew" label="Crew" />
</Group>
<Textarea
label="Comments"
mt="md"
autosize
minRows={4}
{...form.getInputProps("comments")}
/>
{initialValues?.existing_images?.length ?? 0 > 0 ? (
<ImageListInput
form={form}
field="existing_images"
mt="md"
label="Existing Images"
// canAdd={false}
/>
) : null}
<ImageUpload
form={form}
mt="md"
field="images"
label="Images"
placeholder="Upload Images"
/>
</Fieldset>
</Container>
</ScrollArea.Autosize>
<Group justify="flex-end" mt="md">
{isPending ? (
<Loader />
) : isError ? (
<Text c="red" fw={700}>
{error?.message}
</Text>
) : null}
{withCancelButton ? (
<Button onClick={cancelFunc} color="gray">
Cancel
</Button>
) : null}
<Button type="submit" leftSection={<IconPencil />}>
{submitButtonLabel ?? "Create"}
</Button>
</Group>
</form>
</>
);
}

View File

@ -0,0 +1,79 @@
import { FlightFormSchema } from "@/util/types";
import { CloseButton, NumberInput } from "@mantine/core";
import { UseFormReturnType } from "@mantine/form";
function HourInput({
form,
field,
label,
}: {
form: UseFormReturnType<
FlightFormSchema,
(values: FlightFormSchema) => FlightFormSchema
>;
field: string;
label: string;
}) {
const field_key = field as keyof typeof form.getTransformedValues;
return (
<NumberInput
label={label}
decimalScale={1}
step={0.1}
min={0}
fixedDecimalScale
leftSection={
<CloseButton
aria-label="Clear input"
onClick={() => form.setFieldValue(field, "")}
style={{
display:
["", null].indexOf(form.getTransformedValues()[field_key]) > -1
? "none"
: undefined,
}}
/>
}
{...form.getInputProps(field)}
/>
);
}
function ZeroHourInput({
form,
field,
label,
}: {
form: UseFormReturnType<
FlightFormSchema,
(values: FlightFormSchema) => FlightFormSchema
>;
field: string;
label: string;
}) {
const field_key = field as keyof typeof form.getTransformedValues;
return (
<NumberInput
label={label}
decimalScale={1}
step={0.1}
min={0}
fixedDecimalScale
leftSection={
<CloseButton
aria-label="Clear input"
onClick={() => form.setFieldValue(field, 0)}
style={{
display:
form.getTransformedValues()[field_key] === 0 ? "none" : undefined,
}}
/>
}
{...form.getInputProps(field)}
/>
);
}
export { HourInput, ZeroHourInput };

View File

@ -0,0 +1,75 @@
import { Button, Card, Group, Text } from "@mantine/core";
import SecureImage from "../display/secure-img";
import { randomId } from "@mantine/hooks";
import { IconTrash } from "@tabler/icons-react";
import { UseFormReturnType } from "@mantine/form";
import { FlightFormSchema } from "@/util/types";
export default function ImageListInput({
label,
form,
field,
mt = "sm",
h = "100px",
}: {
label: string;
form: UseFormReturnType<
FlightFormSchema,
(values: FlightFormSchema) => FlightFormSchema
>;
field: string;
mt?: string;
w?: string;
h?: string;
}) {
const field_key = field as keyof typeof form.getTransformedValues;
return (
<>
{/* <Grid> */}
<Text size="sm" fw={700} mt={mt} mb="xs">
{label}
</Text>
<Group display="flex" gap="xs" style={{ flexWrap: "wrap" }}>
{(form.getTransformedValues()[field_key] as string[]).map((id) => (
// <Grid.Col key={randomId()}>
<Card key={randomId()} padding="md" shadow="md" withBorder>
{/* <Card.Section> */}
<SecureImage id={id} h={h} />
{/* </Card.Section> */}
<Button
mt="md"
leftSection={<IconTrash />}
onClick={() =>
form.setFieldValue(
field,
(form.getTransformedValues()[field_key] as string[]).filter(
(i) => i !== id
)
)
}
>
Remove
</Button>
</Card>
// </Grid.Col>
))}
</Group>
</>
// </Grid>
// <PillsInput label={label}>
// <Pill.Group>
// {imageIds.map((id: string) => (
// <Pill
// radius="sm"
// key={id}
// withRemoveButton
// onRemove={() => setImageIds(imageIds.filter((i) => i !== id))}
// >
// <SecureImage id={id} h="20px" />
// </Pill>
// ))}
// </Pill.Group>
// </PillsInput>
);
}

View File

@ -0,0 +1,54 @@
import { FlightFormSchema } from "@/util/types";
import { FileInput, FileInputProps, Pill } from "@mantine/core";
import { UseFormReturnType } from "@mantine/form";
import { randomId } from "@mantine/hooks";
import { IconPhoto } from "@tabler/icons-react";
export default function ImageUpload({
form,
field,
label = "",
placeholder = "",
mt = "",
}: {
form: UseFormReturnType<
FlightFormSchema,
(values: FlightFormSchema) => FlightFormSchema
>;
field: string;
label?: string;
placeholder?: string;
mt?: string;
}) {
const ValueComponent: FileInputProps["valueComponent"] = ({ value }) => {
if (value === null) {
return null;
}
if (Array.isArray(value)) {
return (
<Pill.Group>
{value.map((file) => (
<Pill key={randomId()}>{file.name}</Pill>
))}
</Pill.Group>
);
}
return <Pill>{value.name}</Pill>;
};
return (
<FileInput
label={label}
placeholder={placeholder}
multiple
mt={mt}
accept="image/*"
valueComponent={ValueComponent}
rightSectionPointerEvents="none"
rightSection={<IconPhoto />}
{...form.getInputProps(field)}
/>
);
}

View File

@ -0,0 +1,75 @@
import { FlightFormSchema } from "@/util/types";
import { CloseButton, NumberInput } from "@mantine/core";
import { UseFormReturnType } from "@mantine/form";
function IntInput({
form,
field,
label,
}: {
form: UseFormReturnType<
FlightFormSchema,
(values: FlightFormSchema) => FlightFormSchema
>;
field: string;
label: string;
}) {
const field_key = field as keyof typeof form.getTransformedValues;
return (
<NumberInput
label={label}
min={0}
allowDecimal={false}
leftSection={
<CloseButton
aria-label="Clear input"
onClick={() => form.setFieldValue(field, "")}
style={{
display:
["", null].indexOf(form.getTransformedValues()[field_key]) === 0
? "none"
: undefined,
}}
/>
}
{...form.getInputProps(field)}
/>
);
}
function ZeroIntInput({
form,
field,
label,
}: {
form: UseFormReturnType<
FlightFormSchema,
(values: FlightFormSchema) => FlightFormSchema
>;
field: string;
label: string;
}) {
const field_key = field as keyof typeof form.getTransformedValues;
return (
<NumberInput
label={label}
min={0}
allowDecimal={false}
leftSection={
<CloseButton
aria-label="Clear input"
onClick={() => form.setFieldValue(field, 0)}
style={{
display:
form.getTransformedValues()[field_key] === 0 ? "none" : undefined,
}}
/>
}
{...form.getInputProps(field)}
/>
);
}
export { IntInput, ZeroIntInput };

View File

@ -0,0 +1,79 @@
import { FlightFormSchema } from "@/util/types";
import { Pill, PillsInput } from "@mantine/core";
import { UseFormReturnType } from "@mantine/form";
import { useState } from "react";
export default function ListInput({
form,
field,
label,
mt = "",
canAdd = true,
}: {
form: UseFormReturnType<
FlightFormSchema,
(values: FlightFormSchema) => FlightFormSchema
>;
field: string;
label: string;
mt?: string;
canAdd?: boolean;
}) {
const field_key = field as keyof typeof form.getTransformedValues;
const [inputValue, setInputValue] = useState<string>("");
const handleKeyDown = (event: React.KeyboardEvent) => {
if (event.key === "Enter" || event.key === ",") {
event.preventDefault();
const values = form.getTransformedValues()[field_key] as string[];
const newItem = inputValue.trim();
if (newItem && values.indexOf(newItem) == -1) {
form.setFieldValue(field, [...values, newItem]);
setInputValue("");
}
} else if (event.key === "Backspace") {
const values = form.getTransformedValues()[field_key] as string[];
const newItem = inputValue.trim();
if (newItem === "") {
form.setFieldValue(field, values.slice(0, -1));
}
}
};
return (
<PillsInput
mt={mt}
label={label}
description={canAdd ? "Press enter or comma to add item" : ""}
>
<Pill.Group>
{(form.getTransformedValues()[field_key] as string[]).map(
(item: string) => (
<Pill
radius="sm"
key={item}
withRemoveButton
onRemove={() =>
form.setFieldValue(
field,
(form.getTransformedValues()[field_key] as string[]).filter(
(value: string) => value !== item
)
)
}
>
{item}
</Pill>
)
)}
{canAdd ? (
<PillsInput.Field
value={inputValue}
onChange={(event) => setInputValue(event.currentTarget.value)}
onKeyDown={handleKeyDown}
/>
) : null}
</Pill.Group>
</PillsInput>
);
}

View File

@ -0,0 +1,55 @@
import { FlightFormSchema } from "@/util/types";
import { ActionIcon, CloseButton, NumberInput, Tooltip } from "@mantine/core";
import { UseFormReturnType } from "@mantine/form";
import { IconClock } from "@tabler/icons-react";
import dayjs from "dayjs";
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;
return (
<NumberInput
label={label}
allowDecimal={false}
min={0}
max={2359}
allowLeadingZeros={allowLeadingZeros}
leftSection={
<CloseButton
aria-label="Clear input"
onClick={() => form.setFieldValue(field, "")}
style={{
display:
["", null].indexOf(form.getTransformedValues()[field_key]) > -1
? "none"
: undefined,
}}
/>
}
rightSection={
<Tooltip label="Now">
<ActionIcon
variant="transparent"
mr="sm"
onClick={() => {
form.setFieldValue(field, dayjs().format("HHmm"));
}}
>
<IconClock style={{ width: "70%", height: "70%" }} />
</ActionIcon>
</Tooltip>
}
{...form.getInputProps(field)}
/>
);
}

View File

@ -0,0 +1,71 @@
import { CloseButton, NumberInput } from "@mantine/core";
import { Dispatch, SetStateAction } from "react";
function HourInput({
label,
value,
setValue,
}: {
label: string;
value: number | string | null;
setValue: Dispatch<SetStateAction<string | number | null>>;
}) {
return (
<NumberInput
label={label}
decimalScale={1}
step={0.1}
min={0}
fixedDecimalScale
leftSection={
<CloseButton
aria-label="Clear input"
onClick={() => setValue("")}
style={{
display: Number.isFinite(value) ? undefined : undefined,
}}
/>
}
onChange={(value) =>
setValue(Number.isFinite(value) ? (value as number) : 0)
}
/>
);
}
function ZeroHourInput({
label,
value,
setValue,
error = null,
}: {
label: string;
value: number | null;
setValue: Dispatch<SetStateAction<number | null>>;
error?: string | null;
}) {
return (
<NumberInput
label={label}
decimalScale={1}
step={0.1}
min={0}
fixedDecimalScale
error={error}
leftSection={
<CloseButton
aria-label="Clear input"
onClick={() => setValue(0)}
style={{
display: !value || value === 0 ? "none" : undefined,
}}
/>
}
onChange={(value) =>
setValue(Number.isFinite(value) ? (value as number) : 0)
}
/>
);
}
export { HourInput, ZeroHourInput };

View File

@ -0,0 +1,65 @@
import {
ActionIcon,
Button,
Card,
Collapse,
Group,
Text,
Tooltip,
} from "@mantine/core";
import { Dispatch, SetStateAction, useState } from "react";
import SecureImage from "../display/secure-img";
import { randomId } from "@mantine/hooks";
import { IconMinus, IconPlus, IconTrash } from "@tabler/icons-react";
export default function ImageListInput({
label,
imageIds,
setImageIds,
collapsible = false,
startCollapsed = true,
}: {
label: string;
imageIds: string[];
setImageIds: Dispatch<SetStateAction<string[]>>;
collapsible?: boolean;
startCollapsed?: boolean;
}) {
const [collapsed, setCollapsed] = useState(startCollapsed);
return (
<>
<Group gap="0">
<Text size="sm" fw={700} span>
{label}
</Text>
{collapsible ? (
<Tooltip label={collapsed ? "Expand" : "Collapse"}>
<ActionIcon
variant="transparent"
onClick={() => setCollapsed(!collapsed)}
>
{collapsed ? <IconPlus size="1rem" /> : <IconMinus size="1rem" />}
</ActionIcon>
</Tooltip>
) : null}
</Group>
<Collapse in={!collapsed}>
<Group variant="column">
{imageIds.map((id) => (
<Card key={randomId()} padding="md" shadow="md" withBorder>
<SecureImage id={id} />
<Button
mt="md"
leftSection={<IconTrash />}
onClick={() => setImageIds(imageIds.filter((i) => i !== id))}
>
Remove
</Button>
</Card>
))}
</Group>
</Collapse>
</>
);
}

View File

@ -0,0 +1,52 @@
import { FlightFormSchema } from "@/util/types";
import { FileInput, FileInputProps, Pill } from "@mantine/core";
import { UseFormReturnType } from "@mantine/form";
import { randomId } from "@mantine/hooks";
import { IconPhoto } from "@tabler/icons-react";
import { Dispatch, SetStateAction } from "react";
export default function ImageUpload({
value,
setValue,
label = "",
placeholder = "",
mt = "",
}: {
value: File[];
setValue: Dispatch<SetStateAction<File[]>>;
label?: string;
placeholder?: string;
mt?: string;
}) {
const ValueComponent: FileInputProps["valueComponent"] = ({ value }) => {
if (value === null) {
return null;
}
if (Array.isArray(value)) {
return (
<Pill.Group>
{value.map((file) => (
<Pill key={randomId()}>{file.name}</Pill>
))}
</Pill.Group>
);
}
return <Pill>{value.name}</Pill>;
};
return (
<FileInput
label={label}
placeholder={placeholder}
multiple
mt={mt}
accept="image/*"
valueComponent={ValueComponent}
rightSectionPointerEvents="none"
rightSection={<IconPhoto />}
onChange={setValue}
/>
);
}

View File

@ -0,0 +1,65 @@
import { CloseButton, NumberInput } from "@mantine/core";
import { Dispatch, SetStateAction } from "react";
function IntInput({
label,
value,
setValue,
}: {
label: string;
value: number | string | undefined;
setValue: Dispatch<SetStateAction<number | string | undefined>>;
}) {
return (
<NumberInput
label={label}
min={0}
allowDecimal={false}
value={value}
leftSection={
<CloseButton
aria-label="Clear input"
onClick={() => setValue("")}
style={{
display: Number.isFinite(value) ? "none" : undefined,
}}
/>
}
onChange={setValue}
/>
);
}
function ZeroIntInput({
label,
value,
setValue,
error = null,
}: {
label: string;
value: number;
setValue: Dispatch<SetStateAction<number>>;
error: string | null;
}) {
return (
<NumberInput
label={label}
min={0}
allowDecimal={false}
error={error}
value={value}
leftSection={
<CloseButton
aria-label="Clear input"
onClick={() => setValue(0)}
style={{
display: !value || value === 0 ? "none" : undefined,
}}
/>
}
onChange={(value) => setValue(Number(value))}
/>
);
}
export { IntInput, ZeroIntInput };

View File

@ -0,0 +1,61 @@
import { Pill, PillsInput } from "@mantine/core";
import { Dispatch, SetStateAction, useState } from "react";
export default function ListInput({
label,
value,
setValue,
canAdd = true,
}: {
label: string;
value: string[];
setValue: Dispatch<SetStateAction<string[]>>;
canAdd?: boolean;
}) {
const [inputValue, setInputValue] = useState<string>("");
const handleKeyDown = (event: React.KeyboardEvent) => {
if (event.key === "Enter" || event.key === ",") {
event.preventDefault();
const newItem = inputValue.trim();
if (newItem && value.indexOf(newItem) == -1) {
setValue([...value, newItem]);
setInputValue("");
}
} else if (event.key === "Backspace") {
const newItem = inputValue.trim();
if (newItem === "") {
setValue(value.slice(0, -1));
}
}
};
return (
<PillsInput
label={label}
description={canAdd ? "Press enter or comma to add item" : ""}
>
<Pill.Group>
{value.map((item: string) => (
<Pill
radius="sm"
key={item}
withRemoveButton
onRemove={() =>
setValue(value.filter((value: string) => value !== item))
}
>
{item}
</Pill>
))}
{canAdd ? (
<PillsInput.Field
value={inputValue}
onChange={(event) => setInputValue(event.currentTarget.value)}
onKeyDown={handleKeyDown}
/>
) : null}
</Pill.Group>
</PillsInput>
);
}

View File

@ -0,0 +1,53 @@
import { ActionIcon, CloseButton, NumberInput, Tooltip } from "@mantine/core";
import { IconClock } from "@tabler/icons-react";
import dayjs from "dayjs";
import { Dispatch, SetStateAction } from "react";
export default function TimeInput({
label,
value,
setValue,
allowLeadingZeros = false,
error = "",
}: {
label: string;
value: number | string | undefined;
setValue: Dispatch<SetStateAction<number | string | undefined>>;
allowLeadingZeros?: boolean;
error?: string | null;
}) {
return (
<NumberInput
label={label}
allowDecimal={false}
min={0}
max={2359}
allowLeadingZeros={allowLeadingZeros}
error={error}
value={value}
leftSection={
<CloseButton
aria-label="Clear input"
onClick={() => setValue("")}
style={{
display: Number.isFinite(value) ? "none" : undefined,
}}
/>
}
rightSection={
<Tooltip label="Now">
<ActionIcon
variant="transparent"
mr="sm"
onClick={() => {
setValue(dayjs().format("HHmm"));
}}
>
<IconClock style={{ width: "70%", height: "70%" }} />
</ActionIcon>
</Tooltip>
}
onChange={setValue}
/>
);
}

View File

@ -0,0 +1,52 @@
import {
AppShell,
Burger,
Group,
Title,
UnstyledButton,
Avatar,
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import ThemeToggle from "../theme-toggle";
import { Link, useNavigate } from "@remix-run/react";
import Navbar from "./navbar";
export function TailfinAppShell({ children }: { children: React.ReactNode }) {
const [opened, { toggle }] = useDisclosure();
const navigate = useNavigate();
return (
<AppShell
header={{ height: 60 }}
navbar={{ width: 300, breakpoint: "xl", collapsed: { mobile: !opened } }}
padding="md"
>
<AppShell.Header>
<Group h="100%" justify="space-between" px="md">
<Group h="100%" px="md">
<Burger
opened={opened}
onClick={toggle}
hiddenFrom="xl"
size="sm"
/>
</Group>
<Group gap="xs">
<Avatar src="/logo.png" component={Link} to="/logbook" />
<UnstyledButton onClick={() => navigate("/logbook")}>
<Title order={2} fw="normal">
Tailfin
</Title>
</UnstyledButton>
</Group>
<ThemeToggle />
</Group>
</AppShell.Header>
<AppShell.Navbar>
<Navbar opened={opened} toggle={toggle} />
</AppShell.Navbar>
<AppShell.Main>{children}</AppShell.Main>
</AppShell>
);
}

92
web/app/ui/nav/navbar.tsx Normal file
View File

@ -0,0 +1,92 @@
import { useAuth } from "@/util/auth";
import { Stack, NavLink } from "@mantine/core";
import { Link, useLocation } from "@remix-run/react";
import {
IconAdjustments,
IconBook2,
IconDashboard,
IconLogout,
IconPlaneTilt,
IconUser,
} from "@tabler/icons-react";
export default function Navbar({
opened,
toggle,
}: {
opened: boolean;
toggle: () => void;
}) {
const location = useLocation();
const page = location.pathname.split("/")[2];
const { user, authLevel, signout } = useAuth();
return (
<Stack justify="space-between" h="100%">
<Stack gap="0">
<NavLink
p="md"
component={Link}
to="/logbook/dashboard"
label="Dashboard"
leftSection={<IconDashboard />}
active={page == "dashboard"}
onClick={() => (opened ? toggle() : null)}
/>
<NavLink
p="md"
component={Link}
to="/logbook/flights"
label="Flights"
leftSection={<IconBook2 />}
active={page === "flights"}
onClick={() => (opened ? toggle() : null)}
/>
<NavLink
p="md"
component={Link}
to="/logbook/aircraft"
label="Aircraft"
leftSection={<IconPlaneTilt />}
active={page === "aircraft"}
onClick={() => (opened ? toggle() : null)}
/>
{authLevel ? (
authLevel === 2 ? (
<NavLink
p="md"
component={Link}
to="/logbook/admin"
label="Admin"
leftSection={<IconAdjustments />}
active={page === "admin"}
onClick={() => (opened ? toggle() : null)}
/>
) : null
) : null}
</Stack>
<Stack gap="0">
<NavLink
p="md"
label={user ? user : "Not Logged In"}
leftSection={<IconUser />}
>
<NavLink
p="md"
component={Link}
to="/logbook/me"
label="Account"
leftSection={<IconUser />}
/>
<NavLink
p="md"
onClick={() => signout()}
label="Sign Out"
leftSection={<IconLogout />}
/>
</NavLink>
</Stack>
</Stack>
);
}

View File

@ -0,0 +1,30 @@
import {
ActionIcon,
Tooltip,
useComputedColorScheme,
useMantineColorScheme,
} from "@mantine/core";
import { IconMoonStars, IconSun } from "@tabler/icons-react";
const ThemeToggle = () => {
const { colorScheme, setColorScheme } = useMantineColorScheme();
const comoputedColorScheme = useComputedColorScheme("dark");
const toggleColorScheme = () => {
setColorScheme(comoputedColorScheme === "dark" ? "light" : "dark");
};
return (
<Tooltip label={(colorScheme === "dark" ? "Light" : "Dark") + " Theme"}>
<ActionIcon
variant="default"
radius="xl"
size="lg"
aria-label="Toggle Dark Theme"
onClick={toggleColorScheme}
>
{colorScheme === "dark" ? <IconSun /> : <IconMoonStars />}
</ActionIcon>
</Tooltip>
);
};
export default ThemeToggle;

64
web/app/util/api.tsx Normal file
View File

@ -0,0 +1,64 @@
import axios, { AxiosInstance } from "axios";
import { createContext, useContext } from "react";
const ApiContext = createContext<AxiosInstance | null>(null);
export function ApiProvider({
children,
apiUrl,
}: {
children: React.ReactNode;
apiUrl: string;
}) {
const api = useProvideApi(apiUrl);
return <ApiContext.Provider value={api}>{children}</ApiContext.Provider>;
}
export function useApi(): AxiosInstance {
const api = useContext(ApiContext);
if (!api) throw new Error("Could not find API provider");
return api;
}
function useProvideApi(apiUrl: string) {
const client = axios.create({
baseURL: apiUrl,
headers: { "Access-Control-Allow-Origin": "*" },
});
client.interceptors.request.use(
(config) => {
const token = localStorage.getItem("token");
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
async (error) => {
console.log(error.response);
if (error.response && error.response.status === 401) {
try {
const refreshToken = localStorage.getItem("refresh-token");
const response = await client.post("/auth/refresh", { refreshToken });
const newAccessToken = response.data.access_token;
localStorage.setItem("token", newAccessToken);
error.config.headers.Authorization = `Bearer ${newAccessToken}`;
return client(error.config);
} catch (err) {
localStorage.removeItem("token");
localStorage.removeItem("refresh-token");
window.location.href = "/login";
}
}
return Promise.reject(error);
}
);
return client;
}
export function createClient() {
return axios.create({});
}

123
web/app/util/auth.tsx Normal file
View File

@ -0,0 +1,123 @@
import { useApi } from "./api";
import { useNavigate } from "@remix-run/react";
import { AxiosError } from "axios";
import { createContext, useContext, useEffect, useState } from "react";
interface AuthContextValues {
user: string | null;
authLevel: number | null;
loading: boolean;
signin: ({
username,
password,
}: {
username: string;
password: string;
}) => Promise<string | void>;
signout: () => void;
clearUser: () => void;
}
const AuthContext = createContext<AuthContextValues | null>(null);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const auth = useProvideAuth();
return <AuthContext.Provider value={auth}>{children}</AuthContext.Provider>;
}
export function useAuth(): AuthContextValues {
const data = useContext(AuthContext);
if (!data) {
throw new Error("Could not find AuthContext provider");
}
return data;
}
function useProvideAuth() {
const [user, setUser] = useState<string | null>(null);
const [authLevel, setAuthLevel] = useState<number | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const navigate = useNavigate();
const client = useApi();
const handleUser = (rawUser: string | null) => {
if (rawUser) {
setUser(rawUser);
setLoading(false);
return rawUser;
} else {
setUser(null);
clearTokens();
setLoading(false);
return false;
}
};
const handleTokens = (tokens: {
access_token: string;
refresh_token: string;
}) => {
if (tokens) {
localStorage.setItem("token", tokens.access_token);
localStorage.setItem("refresh-token", tokens.refresh_token);
}
};
const clearTokens = () => {
localStorage.removeItem("token");
localStorage.removeItem("refresh-token");
};
const signin = async (values: { username: string; password: string }) => {
setLoading(true);
await client
.postForm("/auth/login", values)
.then((response) => handleTokens(response.data))
.catch((err: AxiosError) => {
setLoading(false);
if (err.response?.status === 401)
throw new Error("Invalid username or password");
throw new Error(err.message);
});
setLoading(false);
await client
.get("/users/me")
.then((response) => {
handleUser(response.data.username);
setAuthLevel(response.data.level);
})
.catch(() => handleUser(null));
navigate("/logbook");
};
const signout = async () => {
return await client.post("/auth/logout").then(() => handleUser(null));
};
const clearUser = () => {
handleUser(null);
};
useEffect(() => {
client
.get("/users/me")
.then((response) => {
handleUser(response.data.username);
setAuthLevel(response.data.level);
})
.catch(() => handleUser(null));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return {
user,
authLevel,
loading,
signin,
signout,
clearUser,
};
}

54
web/app/util/hooks.ts Normal file
View File

@ -0,0 +1,54 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useApi } from "./api";
export function useAircraft() {
const client = useApi();
const aircraft = useQuery({
queryKey: ["aircraft-list"],
queryFn: async () => await client.get(`/aircraft`).then((res) => res.data),
});
return aircraft;
}
export function useFlights(filter: string = "", value: string = "") {
const client = useApi();
const flights = useQuery({
queryKey: ["flights-list"],
queryFn: async () =>
await client
.get(
`/flights/by-date?order=1${
filter !== "" && value !== ""
? `&filter=${filter}&value=${value}`
: ""
}`
)
.then((res) => res.data),
});
return flights;
}
export function usePatchFlight(
id: string,
field: string,
onSuccess: () => void
) {
const client = useApi();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (value: string | string[] | number | Date | null) =>
await client
.patch(`/flights/${id}`, { [field]: value })
.then((res) => res.data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [id] });
queryClient.invalidateQueries({ queryKey: ["flights-list"] });
onSuccess();
},
});
}

208
web/app/util/types.ts Normal file
View File

@ -0,0 +1,208 @@
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc.js";
dayjs.extend(utc);
/* FLIGHTS */
type FlightBaseSchema = {
aircraft: string | null;
waypoint_from: string | null;
waypoint_to: string | null;
route: string | null;
hobbs_start: number | null;
hobbs_end: number | null;
time_total: number;
time_pic: number;
time_sic: number;
time_night: number;
time_solo: number;
time_xc: number;
dist_xc: number;
landings_day: number;
landings_night: number;
time_instrument: number;
time_sim_instrument: number;
holds_instrument: number;
dual_given: number;
dual_recvd: number;
time_sim: number;
time_ground: number;
tags: string[];
pax: string[];
crew: string[];
comments: string;
};
type FlightFormSchema = FlightBaseSchema & {
date: dayjs.Dayjs;
time_start: number | null;
time_off: number | null;
time_down: number | null;
time_stop: number | null;
existing_images?: string[] | null;
images: File[] | string[];
};
type FlightCreateSchema = FlightBaseSchema & {
date: string;
time_start: string | null;
time_off: string | null;
time_down: string | null;
time_stop: string | null;
};
type FlightDisplaySchema = FlightBaseSchema & {
id: string;
user: string;
date: dayjs.Dayjs;
time_start: number | null;
time_off: number | null;
time_down: number | null;
time_stop: number | null;
images: string[] | null;
};
type FlightConciseSchema = {
user: string;
id: string;
date: string;
aircraft: string;
waypoint_from: string;
waypoint_to: string;
time_total: number;
comments: string;
};
const flightCreateHelper = (
values: FlightFormSchema
): FlightCreateSchema | void => {
const date = dayjs(values.date);
try {
const newFlight = {
...values,
date: date.utc().startOf("day").toISOString(),
hobbs_start: values.hobbs_start ? Number(values.hobbs_start) : null,
hobbs_end: values.hobbs_end ? Number(values.hobbs_end) : null,
time_start: values.time_start
? date
.utc()
.hour(Math.floor((values.time_start ?? 0) / 100))
.minute(Math.floor((values.time_start ?? 0) % 100))
.second(0)
.millisecond(0)
.toISOString()
: null,
time_off: values.time_off
? date
.utc()
.hour(Math.floor((values.time_off ?? 0) / 100))
.minute(Math.floor((values.time_off ?? 0) % 100))
.second(0)
.millisecond(0)
.toISOString()
: null,
time_down: values.time_down
? date
.utc()
.hour(Math.floor((values.time_down ?? 0) / 100))
.minute(Math.floor((values.time_down ?? 0) % 100))
.second(0)
.millisecond(0)
.toISOString()
: null,
time_stop: values.time_stop
? date
.utc()
.hour(Math.floor((values.time_stop ?? 0) / 100))
.minute(Math.floor((values.time_stop ?? 0) % 100))
.second(0)
.millisecond(0)
.toISOString()
: null,
};
return newFlight;
} catch (err) {
console.log(err);
}
};
const flightEditHelper = (
values: FlightDisplaySchema
): FlightFormSchema | void => {
try {
const flight = {
...values,
date: dayjs(values.date),
time_start: Number(
`${dayjs(values.time_start).hour()}${dayjs(values.time_start).minute()}`
),
time_off: Number(
`${dayjs(values.time_off).hour()}${dayjs(values.time_off).minute()}`
),
time_down: Number(
`${dayjs(values.time_down).hour()}${dayjs(values.time_down).minute()}`
),
time_stop: Number(
`${dayjs(values.time_stop).hour()}${dayjs(values.time_stop).minute()}`
),
existing_images: values.images as string[],
images: [],
};
return flight;
} catch (err) {
console.log(err);
}
};
/* AIRCRAFT */
type AircraftSchema = {
id: string;
tail_no: string;
make: string;
model: string;
aircraft_category: string;
aircraft_class: string;
hobbs: number;
};
type AircraftFormSchema = {
tail_no: string;
make: string;
model: string;
aircraft_category: string;
aircraft_class: string;
hobbs: number;
};
export {
flightEditHelper,
flightCreateHelper,
type FlightFormSchema,
type FlightCreateSchema,
type FlightDisplaySchema,
type FlightConciseSchema,
type AircraftSchema,
type AircraftFormSchema,
};

12653
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

61
web/package.json Normal file
View File

@ -0,0 +1,61 @@
{
"name": "tailfin-web",
"private": true,
"sideEffects": false,
"type": "module",
"scripts": {
"build": "remix build",
"dev": "remix dev --manual",
"lint": "eslint --ignore-path .gitignore --cache --cache-location ./node_modules/.cache/eslint .",
"start": "remix-serve ./build/index.js",
"typecheck": "tsc"
},
"dependencies": {
"@mantine/carousel": "^7.4.1",
"@mantine/core": "^7.4.1",
"@mantine/dates": "^7.4.1",
"@mantine/dropzone": "^7.4.1",
"@mantine/form": "^7.4.1",
"@mantine/hooks": "^7.4.1",
"@mantine/modals": "^7.4.1",
"@mantine/notifications": "^7.4.1",
"@remix-run/css-bundle": "^2.4.1",
"@remix-run/node": "^2.4.1",
"@remix-run/react": "^2.4.1",
"@remix-run/serve": "^2.4.1",
"@tabler/icons-react": "^2.44.0",
"@tanstack/react-query": "^5.17.0",
"@tanstack/react-query-devtools": "^5.17.0",
"axios": "^1.6.3",
"clsx": "^2.1.0",
"dayjs": "^1.11.10",
"dayjs-plugin-utc": "^0.1.2",
"embla-carousel-react": "^7.1.0",
"isbot": "^3.6.8",
"mantine-datatable": "^7.4.4",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"use-dehydrated-state": "^0.1.0"
},
"devDependencies": {
"@remix-run/dev": "^2.4.1",
"@tanstack/eslint-plugin-query": "^5.14.6",
"@types/react": "^18.2.20",
"@types/react-dom": "^18.2.7",
"@typescript-eslint/eslint-plugin": "^6.7.4",
"eslint": "^8.38.0",
"eslint-config-prettier": "^9.0.0",
"eslint-import-resolver-typescript": "^3.6.1",
"eslint-plugin-import": "^2.28.1",
"eslint-plugin-jsx-a11y": "^6.7.1",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0",
"postcss": "^8.4.32",
"postcss-preset-mantine": "^1.12.2",
"postcss-simple-vars": "^7.0.1",
"typescript": "^5.1.6"
},
"engines": {
"node": ">=18.0.0"
}
}

14
web/postcss.config.cjs Normal file
View File

@ -0,0 +1,14 @@
module.exports = {
plugins: {
"postcss-preset-mantine": {},
"postcss-simple-vars": {
variables: {
"mantine-breakpoint-xs": "36em",
"mantine-breakpoint-sm": "48em",
"mantine-breakpoint-md": "62em",
"mantine-breakpoint-lg": "75em",
"mantine-breakpoint-xl": "88em",
},
},
},
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 799 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -0,0 +1,19 @@
{
"name": "",
"short_name": "",
"icons": [
{
"src": "/favicon/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/favicon/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}

BIN
web/public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

BIN
web/public/mockup.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

29
web/remix.config.js Normal file
View File

@ -0,0 +1,29 @@
import { defineRoutes } from "@remix-run/dev/dist/config/routes.js";
/** @type {import('@remix-run/dev').AppConfig} */
export default {
postcss: true,
ignoredRouteFiles: ["**/.*"],
routes(defineRoutes) {
return defineRoutes((route) => {
route("/", "routes/_index.tsx", { index: true });
route("logbook", "routes/logbook/route.tsx", () => {
route("dashboard", "routes/logbook/dashboard.tsx");
route("admin", "routes/logbook/admin.tsx");
route("me", "routes/logbook/me.tsx");
route("aircraft", "routes/logbook/aircraft.tsx");
route("flights", "routes/logbook/flights/route.tsx", () => {
route("", "routes/logbook/flights/_index.tsx", { index: true });
route(":id", "routes/logbook/flights/$id.tsx");
route("new", "routes/logbook/flights/new.tsx");
route("edit/:id", "routes/logbook/flights/edit/$id.tsx");
});
});
route("login", "routes/login.tsx");
});
},
// appDirectory: "app",
// assetsBuildDirectory: "public/build",
// publicPath: "/build/",
// serverBuildPath: "build/index.js",
};

2
web/remix.env.d.ts vendored Normal file
View File

@ -0,0 +1,2 @@
/// <reference types="@remix-run/dev" />
/// <reference types="@remix-run/node" />

22
web/tsconfig.json Normal file
View File

@ -0,0 +1,22 @@
{
"include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"],
"compilerOptions": {
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"isolatedModules": true,
"esModuleInterop": true,
"jsx": "react-jsx",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"target": "ES2022",
"strict": true,
"allowJs": true,
"forceConsistentCasingInFileNames": true,
"baseUrl": ".",
"paths": {
"@/*": ["./app/*"]
},
// Remix takes care of building everything in `remix build`.
"noEmit": true
}
}