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
+
+A self-hosted digital flight logbook
+
+
+
+
+
+
+
+
+
+
+
+## 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"}
+
+
+ }
+ component={Link}
+ to="/logbook"
+ variant="default"
+ >
+ Get me out of here!
+
+
+
+
+
+
+
+ );
+}
+
+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}
+
+ deleteAircraft.mutate()}>
+ Delete
+
+
+ Cancel
+
+
+
+
+
+
+ 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}
+ deleteFlight.mutate()}>
+ Delete
+
+
+ Cancel
+
+
+
+
+
+
+ {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 (
+ ({
+ 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 (
+
+
+
+ }
+ onClick={() => navigate("/logbook/flights/new")}
+ >
+ New Flight
+
+
+
+
+
+
+ );
+}
+
+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 (
+
+
+
+ {" "}
+
+
+ }
+ onClick={() => navigate("/logbook/flights/new")}
+ >
+ Add
+
+
+
+ );
+}
+
+export default { FlightsList, MobileFlightsList };
diff --git a/web/app/routes/logbook/flights/new.tsx b/web/app/routes/logbook/flights/new.tsx
new file mode 100644
index 0000000..518684a
--- /dev/null
+++ b/web/app/routes/logbook/flights/new.tsx
@@ -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 (
+
+
+ New Flight
+
+
+
+
+ );
+}
diff --git a/web/app/routes/logbook/flights/route.tsx b/web/app/routes/logbook/flights/route.tsx
new file mode 100644
index 0000000..6dd4153
--- /dev/null
+++ b/web/app/routes/logbook/flights/route.tsx
@@ -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 (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/web/app/routes/logbook/me.tsx b/web/app/routes/logbook/me.tsx
new file mode 100644
index 0000000..9e0f387
--- /dev/null
+++ b/web/app/routes/logbook/me.tsx
@@ -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 (
+
+ {user.isLoading ? (
+
+
+
+ ) : user.isError ? (
+
+
+
+ ) : user.data ? (
+
+
+
+ {user.data.username}
+
+ {user.data.level === 2
+ ? "Admin"
+ : user.data.level === 1
+ ? "User"
+ : "Guest"}
+ {" "}
+
+
+
+ ) : (
+ Unknown Error
+ )}
+
+ );
+}
diff --git a/web/app/routes/logbook/route.tsx b/web/app/routes/logbook/route.tsx
new file mode 100644
index 0000000..2128b4f
--- /dev/null
+++ b/web/app/routes/logbook/route.tsx
@@ -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 (
+
+
+
+
+
+
+ );
+}
diff --git a/web/app/routes/login.tsx b/web/app/routes/login.tsx
new file mode 100644
index 0000000..9a0c437
--- /dev/null
+++ b/web/app/routes/login.tsx
@@ -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 (
+
+
+
+
+
+
+ Tailfin
+
+
+
+
+
+
+
+
+ );
+}
+
+export default function Login() {
+ const queryClient = new QueryClient();
+
+ return (
+
+
+
+ );
+}
diff --git a/web/app/ui/display/collapsible-fieldset.tsx b/web/app/ui/display/collapsible-fieldset.tsx
new file mode 100644
index 0000000..c009071
--- /dev/null
+++ b/web/app/ui/display/collapsible-fieldset.tsx
@@ -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 (
+
+ {legend ? {legend} : null}
+
+ {open ? : }
+
+
+ }
+ w={w}
+ mt={mt}
+ >
+ {children}
+
+ );
+}
diff --git a/web/app/ui/display/editable/aircraft-log-item.tsx b/web/app/ui/display/editable/aircraft-log-item.tsx
new file mode 100644
index 0000000..c6d0610
--- /dev/null
+++ b/web/app/ui/display/editable/aircraft-log-item.tsx
@@ -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(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 = (
+
+
+ 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}
+ {
+ if (editValue.length === 0) {
+ setEditError("Please select an aircraft");
+ } else {
+ updateValue.mutate(editValue);
+ }
+ }}
+ leftSection={ }
+ >
+ Update
+
+
+
+
+
+
+ {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}
+ {
+ if (editValue === null) {
+ setEditError("Please select a date");
+ } else {
+ updateValue.mutate(
+ dayjs(editValue).utc().startOf("day").toISOString()
+ );
+ }
+ }}
+ leftSection={ }
+ >
+ Update
+
+
+
+
+
+
+
+ {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}
+ {
+ if (editValue === null || editValue < 0) {
+ setEditError("Please enter a valid hour number");
+ } else {
+ updateValue.mutate(editValue);
+ }
+ }}
+ leftSection={ }
+ >
+ Update
+
+
+
+
+
+
+
+ {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}
+ {
+ updateValue.mutate();
+ }}
+ leftSection={ }
+ >
+ Update
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {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}
+ {
+ if (editValue === null || editValue < 0) {
+ setEditError("Please enter a valid number");
+ } else {
+ updateValue.mutate(editValue);
+ }
+ }}
+ leftSection={ }
+ >
+ Update
+
+
+
+
+
+
+
+ {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}
+ {
+ updateValue.mutate(editValue);
+ }}
+ leftSection={ }
+ >
+ Update
+
+
+
+
+
+
+
+ {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 = (
+