add web frontend from standalone repo
80
web/.eslintrc.cjs
Normal 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
@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
|
||||
/.cache
|
||||
/build
|
||||
/public/build
|
||||
.env
|
15
web/.vscode/launch.json
vendored
Normal 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
@ -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
@ -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>
|
||||
|
||||

|
||||
|
||||
<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
@ -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
@ -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
@ -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
@ -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 />;
|
||||
}
|
13
web/app/routes/logbook/admin.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
221
web/app/routes/logbook/aircraft.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
153
web/app/routes/logbook/dashboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
403
web/app/routes/logbook/flights/$id.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
19
web/app/routes/logbook/flights/_index.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
111
web/app/routes/logbook/flights/edit/$id.tsx
Normal 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>
|
||||
);
|
||||
}
|
327
web/app/routes/logbook/flights/flights-list.tsx
Normal 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 };
|
69
web/app/routes/logbook/flights/new.tsx
Normal 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>
|
||||
);
|
||||
}
|
24
web/app/routes/logbook/flights/route.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
134
web/app/routes/logbook/me.tsx
Normal 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>
|
||||
);
|
||||
}
|
70
web/app/routes/logbook/route.tsx
Normal 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
@ -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>
|
||||
);
|
||||
}
|
35
web/app/ui/display/collapsible-fieldset.tsx
Normal 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>
|
||||
);
|
||||
}
|
170
web/app/ui/display/editable/aircraft-log-item.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
105
web/app/ui/display/editable/date-log-item.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
99
web/app/ui/display/editable/hour-log-item.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
28
web/app/ui/display/editable/img-log-item.module.css
Normal 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);
|
||||
}
|
||||
}
|
150
web/app/ui/display/editable/img-log-item.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
99
web/app/ui/display/editable/int-log-item.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
115
web/app/ui/display/editable/list-log-item.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
88
web/app/ui/display/editable/text-log-item.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
120
web/app/ui/display/editable/time-log-item.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
84
web/app/ui/display/log-item.tsx
Normal 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>
|
||||
);
|
||||
}
|
99
web/app/ui/display/secure-img.tsx
Normal 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();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
13
web/app/ui/error-display.tsx
Normal 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>
|
||||
);
|
||||
}
|
186
web/app/ui/form/aircraft-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
556
web/app/ui/form/flight-form.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
79
web/app/ui/form/hour-input.tsx
Normal 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 };
|
75
web/app/ui/form/image-list-input.tsx
Normal 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>
|
||||
);
|
||||
}
|
54
web/app/ui/form/image-upload.tsx
Normal 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)}
|
||||
/>
|
||||
);
|
||||
}
|
75
web/app/ui/form/int-input.tsx
Normal 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 };
|
79
web/app/ui/form/list-input.tsx
Normal 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>
|
||||
);
|
||||
}
|
55
web/app/ui/form/time-input.tsx
Normal 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)}
|
||||
/>
|
||||
);
|
||||
}
|
71
web/app/ui/input/hour-input.tsx
Normal 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 };
|
65
web/app/ui/input/image-list-input.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
52
web/app/ui/input/image-upload.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
65
web/app/ui/input/int-input.tsx
Normal 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 };
|
61
web/app/ui/input/list-input.tsx
Normal 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>
|
||||
);
|
||||
}
|
53
web/app/ui/input/time-input.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
52
web/app/ui/nav/app-shell.tsx
Normal 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
@ -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>
|
||||
);
|
||||
}
|
30
web/app/ui/theme-toggle.tsx
Normal 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
@ -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
@ -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
@ -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
@ -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
61
web/package.json
Normal 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
@ -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",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
BIN
web/public/favicon/android-chrome-192x192.png
Normal file
After Width: | Height: | Size: 28 KiB |
BIN
web/public/favicon/android-chrome-512x512.png
Normal file
After Width: | Height: | Size: 164 KiB |
BIN
web/public/favicon/apple-touch-icon.png
Normal file
After Width: | Height: | Size: 25 KiB |
BIN
web/public/favicon/favicon-16x16.png
Normal file
After Width: | Height: | Size: 799 B |
BIN
web/public/favicon/favicon-32x32.png
Normal file
After Width: | Height: | Size: 2.0 KiB |
BIN
web/public/favicon/favicon.ico
Normal file
After Width: | Height: | Size: 15 KiB |
19
web/public/favicon/site.webmanifest
Normal 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
After Width: | Height: | Size: 243 KiB |
BIN
web/public/mockup.png
Normal file
After Width: | Height: | Size: 1.1 MiB |
29
web/remix.config.js
Normal 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
@ -0,0 +1,2 @@
|
||||
/// <reference types="@remix-run/dev" />
|
||||
/// <reference types="@remix-run/node" />
|
22
web/tsconfig.json
Normal 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
|
||||
}
|
||||
}
|