diff --git a/web/.eslintrc.cjs b/web/.eslintrc.cjs new file mode 100644 index 0000000..edd3094 --- /dev/null +++ b/web/.eslintrc.cjs @@ -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, + }, + }, + ], +}; diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..3f7bf98 --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,6 @@ +node_modules + +/.cache +/build +/public/build +.env diff --git a/web/.vscode/launch.json b/web/.vscode/launch.json new file mode 100644 index 0000000..b6b5762 --- /dev/null +++ b/web/.vscode/launch.json @@ -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}" + } + ] +} diff --git a/web/LICENSE b/web/LICENSE new file mode 100644 index 0000000..adce694 --- /dev/null +++ b/web/LICENSE @@ -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. diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..eaf9a23 --- /dev/null +++ b/web/README.md @@ -0,0 +1,84 @@ +

+ + Tailfin Logo +

+ +

Tailfin

+ +

A self-hosted digital flight logbook

+ +![Screenshots](public/mockup.png) + +

+ + TypeScript + React + Remix-Run + TanStack Query +

+ +## Table of Contents + +- [About](#about) +- [Getting Started](#getting_started) + - [Prerequisites](#prerequisites) + - [Installation](#installation) + - [Configuration](#configuration) +- [Usage](#usage) +- [Roadmap](#roadmap) + +## About + +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 + +### Prerequisites + +- npm +- [tailfin-api](https://github.com/azpsen/tailfin-api) + +### Installation + +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 + +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 + +Once running, the web app can be accessed at `localhost:3000` + +## Roadmap + +- [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 diff --git a/web/app/entry.client.tsx b/web/app/entry.client.tsx new file mode 100644 index 0000000..94d5dc0 --- /dev/null +++ b/web/app/entry.client.tsx @@ -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, + + + + ); +}); diff --git a/web/app/entry.server.tsx b/web/app/entry.server.tsx new file mode 100644 index 0000000..e2002b0 --- /dev/null +++ b/web/app/entry.server.tsx @@ -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( + , + { + 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( + , + { + 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); + }); +} diff --git a/web/app/root.tsx b/web/app/root.tsx new file mode 100644 index 0000000..6a475e7 --- /dev/null +++ b/web/app/root.tsx @@ -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 ( + + + Oops! + + + + + + + + + + + + {isRouteErrorResponse(error) + ? `Error ${error.status} - ${error.statusText}` + : error instanceof Error + ? error.message + : "Unknown Error"} + + + + + + + + + + ); +} + +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(); + + return ( + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/web/app/routes/_index.tsx b/web/app/routes/_index.tsx new file mode 100644 index 0000000..a29a2bb --- /dev/null +++ b/web/app/routes/_index.tsx @@ -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 ; +} diff --git a/web/app/routes/logbook/admin.tsx b/web/app/routes/logbook/admin.tsx new file mode 100644 index 0000000..10528d8 --- /dev/null +++ b/web/app/routes/logbook/admin.tsx @@ -0,0 +1,13 @@ +import { Container, Group, Title } from "@mantine/core"; + +export default function Admin() { + return ( + <> + + + Admin + + + + ); +} diff --git a/web/app/routes/logbook/aircraft.tsx b/web/app/routes/logbook/aircraft.tsx new file mode 100644 index 0000000..6d85c0e --- /dev/null +++ b/web/app/routes/logbook/aircraft.tsx @@ -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 ( + <> + + + + Are you sure you want to delete this aircraft? This action cannot be + undone. + + {deleteAircraft.isError ? ( + + {deleteAircraft.error.message} + + ) : null} + + + + + + + + + updateAircraft.mutate(values) + } + initialValues={aircraft} + submitButtonLabel="Update" + withCancelButton + cancelFunc={closeEdit} + /> + + + + + {aircraft.tail_no} + + + + + + + + + + + {aircraft.make} + {aircraft.model} + + + {aircraft.aircraft_category} + / + {aircraft.aircraft_class} + + {aircraft.hobbs ? Hobbs: {aircraft.hobbs} : null} + + + + ); +} + +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 ( + + + + ); +} + +export default function Aircraft() { + const aircraft: UseQueryResult = useAircraft(); + + const [newOpened, { open: openNew, close: closeNew }] = useDisclosure(false); + + return ( + <> + + + + Aircraft + + + + + + + + + + {aircraft.isLoading ? ( +
+ +
+ ) : aircraft.isError ? ( +
+ +
+ ) : aircraft.data && aircraft.data.length === 0 ? ( +
+ + + No Aircraft + +
+ ) : ( + + {aircraft.data?.map((item) => ( + + ))} + + )} +
+
+ + ); +} diff --git a/web/app/routes/logbook/dashboard.tsx b/web/app/routes/logbook/dashboard.tsx new file mode 100644 index 0000000..0183c54 --- /dev/null +++ b/web/app/routes/logbook/dashboard.tsx @@ -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 ( + + {totals.isLoading ? ( +
+ +
+ ) : totals.isError ? ( +
+ +
+ ) : ( + + Totals + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {totalsData?.by_class?.map((category) => ( + + + {category.aircraft_category} + + + {category.classes.map((total) => ( + <> + + + ))} + + + ))} + + + + )} +
+ ); +} diff --git a/web/app/routes/logbook/flights/$id.tsx b/web/app/routes/logbook/flights/$id.tsx new file mode 100644 index 0000000..d896ebf --- /dev/null +++ b/web/app/routes/logbook/flights/$id.tsx @@ -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([]); + + 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 ( + <> + + + + Are you sure you want to delete this flight? This action cannot be + undone. + + {deleteFlight.isError ? ( + + {deleteFlight.error.message} + + ) : null} + + {deleteFlight.isPending ? : null} + + + + + + + + {flight.isError ? ( +
+ +
+ ) : flight.isPending ? ( +
+ +
+ ) : flight.data ? ( + <> + + + Flight Log + + + + navigate(`/logbook/flights/edit/${params.id}`) + } + > + + + + + + + + + + + + + + + {imageIds.length > 0 ? ( + + + + ) : null} + + + + + + {(log.pax || log.crew) && + (log.pax.length > 0 || log.crew.length > 0) ? ( + + + + + ) : null} + {log.tags && log.tags.length > 0 ? ( + + + + ) : null} + {log.comments?.length > 0 ? ( + + + + ) : null} + + {log.waypoint_from || log.waypoint_to || log.route ? ( + + {log.waypoint_from || log.waypoint_to ? ( + + + + + ) : null} + {log.route ? ( + + + + ) : null} + + ) : null} + {log.hobbs_start || log.hobbs_end ? ( + + + + + + + ) : null} + {log.time_start || + log.time_off || + log.time_down || + log.time_stop ? ( + + {log.time_start || log.time_off ? ( + + + + + ) : null} + {log.time_down || log.time_stop ? ( + + + + + ) : null} + + ) : null} + + + + + + + + + + + + {log.time_xc || log.dist_xc ? ( + + + + + + + ) : null} + + + + + + + {log.time_instrument || + log.time_sim_instrument || + log.holds_instrument ? ( + + + + + + + + ) : null} + + + + + ) : ( +
+ +
+ )} +
+
+ + ); +} diff --git a/web/app/routes/logbook/flights/_index.tsx b/web/app/routes/logbook/flights/_index.tsx new file mode 100644 index 0000000..821aba7 --- /dev/null +++ b/web/app/routes/logbook/flights/_index.tsx @@ -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 ( + <> + + + +
Select a flight
+
+
+ + + + + ); +} diff --git a/web/app/routes/logbook/flights/edit/$id.tsx b/web/app/routes/logbook/flights/edit/$id.tsx new file mode 100644 index 0000000..4351f48 --- /dev/null +++ b/web/app/routes/logbook/flights/edit/$id.tsx @@ -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 ( + + + Edit Flight + + {flight.isLoading ? ( +
+ +
+ ) : flight.isError ? ( +
+ +
+ ) : ( + navigate(`/logbook/flights/${params.id}`)} + mah="calc(100vh - 95px - 110px)" + autofillHobbs={false} + /> + )} +
+
+ ); +} diff --git a/web/app/routes/logbook/flights/flights-list.tsx b/web/app/routes/logbook/flights/flights-list.tsx new file mode 100644 index 0000000..3d3ba66 --- /dev/null +++ b/web/app/routes/logbook/flights/flights-list.tsx @@ -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 ? ( +
+ + +
No flights
+
+
+ ) : ( + Object.entries(flights.data) + .reverse() + .map(([year, months]) => ( + <> + + <> + + {Object.entries(months) + .reverse() + .map(([month, days]) => ( + + + {Object.entries(days) + .reverse() + .map(([, logs]) => ( + <> + {logs + .reverse() + .map((flight: FlightConciseSchema) => ( + <> + + + {flight.date} + + + {`${Number( + flight.time_total + ).toFixed(1)} hr`} + + {flight.waypoint_from || + flight.waypoint_to ? ( + <> + / + + {flight.waypoint_from ? ( + + {flight.waypoint_from} + + ) : ( + "" + )} + {flight.waypoint_from && + flight.waypoint_to ? ( + + ) : null} + {flight.waypoint_to ? ( + + {flight.waypoint_to} + + ) : ( + "" + )} + + + ) : null} + + } + description={ + + {flight.comments + ? flight.comments + : "(No Comment)"} + + } + rightSection={ + flight.aircraft ? ( + + } + color="gray" + size="lg" + > + {flight.aircraft} + + ) : null + } + active={params.id === flight.id} + /> + + + ))} + + ))} + + ))} + + + + )) + ) + ) : flights.isLoading ? ( +
+ +
+ ) : flights.isError ? ( + + ) : ( +
+ No Flights +
+ )} + + ); +} + +function AircraftFilter({ + aircraft, + setAircraft, + query = "flights-list", +}: { + aircraft: string; + setAircraft: (aircraft: string) => void; + query?: string; +}) { + const getAircraft = useAircraft(); + const queryClient = useQueryClient(); + + return ( + + + Aircraft + + + + + + + + + } + 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 ( + <> + + + + + + {editForm} + + {updateValue.isPending ? : null} + {updateValue.isError ? ( + {updateValue.error?.message} + ) : null} + + + + + + + {label} + + + + {content === "" ? : content} + + + + + + + ); +} diff --git a/web/app/ui/display/editable/date-log-item.tsx b/web/app/ui/display/editable/date-log-item.tsx new file mode 100644 index 0000000..157163b --- /dev/null +++ b/web/app/ui/display/editable/date-log-item.tsx @@ -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( + 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 = ( + + ); + + return ( + <> + + + {editForm} + + {updateValue.isPending ? : null} + {updateValue.isError ? ( + {updateValue.error?.message} + ) : null} + + + + + + + + {label} + + + + + {content === "" ? : content} + + + + + + + ); +} diff --git a/web/app/ui/display/editable/hour-log-item.tsx b/web/app/ui/display/editable/hour-log-item.tsx new file mode 100644 index 0000000..2f18200 --- /dev/null +++ b/web/app/ui/display/editable/hour-log-item.tsx @@ -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(content); + + const [editError, setEditError] = useState(""); + + const [editOpened, { open: openEdit, close: closeEdit }] = + useDisclosure(false); + + const updateValue = usePatchFlight(id, field, closeEdit); + + const editForm = ( + + ); + + return ( + <> + + + {editForm} + + {updateValue.isPending ? : null} + {updateValue.isError ? ( + {updateValue.error?.message} + ) : null} + + + + + + + + {label} + + + + + {content === null ? : content} + + + + + + + ); +} diff --git a/web/app/ui/display/editable/img-log-item.module.css b/web/app/ui/display/editable/img-log-item.module.css new file mode 100644 index 0000000..08d650e --- /dev/null +++ b/web/app/ui/display/editable/img-log-item.module.css @@ -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); + } +} diff --git a/web/app/ui/display/editable/img-log-item.tsx b/web/app/ui/display/editable/img-log-item.tsx new file mode 100644 index 0000000..35ecab5 --- /dev/null +++ b/web/app/ui/display/editable/img-log-item.tsx @@ -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(imageIds); + const [newImages, setNewImages] = useState([]); + + 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 ( + <> + + + + + + + {updateValue.isPending ? : null} + {updateValue.isError ? ( + {updateValue.error?.message} + ) : null} + + + + + + + + + + + + + + {imageIds.map((img) => ( + + + + ))} + + + + ); +} diff --git a/web/app/ui/display/editable/int-log-item.tsx b/web/app/ui/display/editable/int-log-item.tsx new file mode 100644 index 0000000..cd88592 --- /dev/null +++ b/web/app/ui/display/editable/int-log-item.tsx @@ -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(content); + + const [editError, setEditError] = useState(""); + + const [editOpened, { open: openEdit, close: closeEdit }] = + useDisclosure(false); + + const updateValue = usePatchFlight(id, field, closeEdit); + + const editForm = ( + + ); + + return ( + <> + + + {editForm} + + {updateValue.isPending ? : null} + {updateValue.isError ? ( + {updateValue.error?.message} + ) : null} + + + + + + + + {label} + + + + + {content === null ? : content} + + + + + + + ); +} diff --git a/web/app/ui/display/editable/list-log-item.tsx b/web/app/ui/display/editable/list-log-item.tsx new file mode 100644 index 0000000..c4e134c --- /dev/null +++ b/web/app/ui/display/editable/list-log-item.tsx @@ -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 ( + + {label} + {content} + + ); +} + +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(content as string[]); + + const [editOpened, { open: openEdit, close: closeEdit }] = + useDisclosure(false); + + const updateValue = usePatchFlight(id, field, closeEdit); + + const editForm = ( + + ); + + return ( + <> + + + {editForm} + + {updateValue.isPending ? : null} + {updateValue.isError ? ( + {updateValue.error?.message} + ) : null} + + + + + + + + {label} + + + + {(content as string[]).length > 0 ? ( + + {(content as string[]).map((item) => ( + + {item} + + ))} + + ) : ( + + + + )} + + + + + + ); +} diff --git a/web/app/ui/display/editable/text-log-item.tsx b/web/app/ui/display/editable/text-log-item.tsx new file mode 100644 index 0000000..c01d4c3 --- /dev/null +++ b/web/app/ui/display/editable/text-log-item.tsx @@ -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(content ?? ""); + + const [editOpened, { open: openEdit, close: closeEdit }] = + useDisclosure(false); + + const updateValue = usePatchFlight(id, field, closeEdit); + + const editForm = ( +