Implement basic API interaction

This commit is contained in:
april 2024-01-02 17:41:11 -06:00
parent a456f8155b
commit 73b11482ff
20 changed files with 2867 additions and 189 deletions

View File

@ -1,18 +1,81 @@
import "@mantine/core/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,
useRouteError,
} from "@remix-run/react";
import {
Button,
ColorSchemeScript,
Container,
Group,
MantineProvider,
Stack,
Title,
} from "@mantine/core";
import { TailfinAppShell } from "./ui/nav/app-shell";
import { IconRocket } from "@tabler/icons-react";
import Providers from "./providers";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
export const links: LinksFunction = () => [
...(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="/"
variant="default"
>
Get me out of here!
</Button>
</Group>
</Stack>
</Container>
</MantineProvider>
</body>
</html>
);
}
const queryClient = new QueryClient();
export default function App() {
return (
<html lang="en">
@ -21,12 +84,18 @@ export default function App() {
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
<ColorSchemeScript />
</head>
<body>
<Outlet />
<ScrollRestoration />
<Scripts />
<LiveReload />
<QueryClientProvider client={queryClient}>
<MantineProvider theme={{ primaryColor: "violet" }}>
<Outlet />
<ScrollRestoration />
<Scripts />
<LiveReload />
</MantineProvider>
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
</body>
</html>
);

View File

@ -1,41 +1,5 @@
import type { MetaFunction } from "@remix-run/node";
import { Outlet } from "@remix-run/react";
export const meta: MetaFunction = () => {
return [
{ title: "New Remix App" },
{ name: "description", content: "Welcome to Remix!" },
];
};
export default function Index() {
return (
<div style={{ fontFamily: "system-ui, sans-serif", lineHeight: "1.8" }}>
<h1>Welcome to Remix</h1>
<ul>
<li>
<a
target="_blank"
href="https://remix.run/tutorials/blog"
rel="noreferrer"
>
15m Quickstart Blog Tutorial
</a>
</li>
<li>
<a
target="_blank"
href="https://remix.run/tutorials/jokes"
rel="noreferrer"
>
Deep Dive Jokes App Tutorial
</a>
</li>
<li>
<a target="_blank" href="https://remix.run/docs" rel="noreferrer">
Remix Docs
</a>
</li>
</ul>
</div>
);
export default function Tailfin() {
return <Outlet />;
}

View File

@ -0,0 +1,40 @@
import { client } from "@/util/api";
import { List, Stack, Text } from "@mantine/core";
import { useParams } from "@remix-run/react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
export default function Flight() {
const params = useParams();
const queryClient = useQueryClient();
const flight = useQuery({
queryKey: [params.id],
queryFn: async () =>
await client.get(`/flights/${params.id}`).then((res) => res.data),
});
return (
<Stack h="calc(100vh - 95px)" m="0" p="0">
{flight.isError ? (
<Text c="red">Error fetching flight</Text>
) : flight.isPending ? (
<Text>Loading...</Text>
) : (
<List>
{Object.entries(flight.data).map(([key, value]) =>
value && value.length !== 0 ? (
<List.Item key={key}>
<Text span>
<Text span fw={700}>
{key}
</Text>
: <Text span>{value}</Text>
</Text>
</List.Item>
) : null
)}
</List>
)}
</Stack>
);
}

View File

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

View File

@ -0,0 +1,85 @@
import { client } from "@/util/api";
import {
Divider,
NavLink,
Text,
Container,
Button,
ScrollArea,
Stack,
} from "@mantine/core";
import { Link, useLocation } from "@remix-run/react";
import { IconPlus } from "@tabler/icons-react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
export function FlightsList() {
const queryClient = useQueryClient();
const flights = useQuery({
queryKey: ["flights-list"],
queryFn: () => client.get(`/flights`).then((res) => res.data),
});
const location = useLocation();
const page = location.pathname.split("/")[3];
return (
<Stack p="0" m="0" gap="0">
<Button variant="outline" leftSection={<IconPlus />} mb="md">
Add
</Button>
<ScrollArea>
{flights.data ? (
flights.data.map((flight, index) => (
<NavLink
key={index}
component={Link}
to={`/logbook/flights/${flight.id}`}
label={`${flight.date}`}
active={page === flight.id}
/>
))
) : (
<Text p="sm">No Flights</Text>
)}
</ScrollArea>
</Stack>
);
}
export function MobileFlightsList() {
const queryClient = useQueryClient();
const flights = useQuery({
queryKey: ["flights-list"],
queryFn: () => client.get(`/flights`).then((res) => res.data),
});
const location = useLocation();
const page = location.pathname.split("/")[3];
return (
<Stack p="0" m="0" justify="space-between" h="calc(100vh - 95px)">
<ScrollArea h="calc(100vh - 95px - 50px">
{flights.data ? (
flights.data.map((flight, index) => (
<NavLink
key={index}
component={Link}
to={`/logbook/flights/${flight.id}`}
label={`${flight.date}`}
active={page === flight.id}
/>
))
) : (
<Text p="sm">No Flights</Text>
)}
</ScrollArea>
<Button variant="outline" leftSection={<IconPlus />} mt="md">
Add
</Button>
</Stack>
);
}
export default { FlightsList, MobileFlightsList };

View File

@ -0,0 +1,22 @@
import { Divider, Grid, Container } from "@mantine/core";
import { Outlet } from "@remix-run/react";
import { FlightsList } from "./flights-list";
export default function FlightsLayout() {
return (
<>
<Grid h="100%" visibleFrom="md">
<Grid.Col span={3}>
<FlightsList />
</Grid.Col>
<Divider orientation="vertical" m="sm" />
<Grid.Col span="auto">
<Outlet />
</Grid.Col>
</Grid>
<Container hiddenFrom="md">
<Outlet />
</Container>
</>
);
}

View File

@ -0,0 +1,12 @@
import { useMe } from "@/util/hooks";
import { Container, Title } from "@mantine/core";
export default function Me() {
const me = useMe();
return (
<Container>
<Title order={2}>{me.data?.username}</Title>
</Container>
);
}

View File

@ -0,0 +1,20 @@
import { TailfinAppShell } from "@/ui/nav/app-shell";
import type { MetaFunction } from "@remix-run/node";
import { Outlet } from "@remix-run/react";
export const meta: MetaFunction = () => {
return [
{ title: "Tailfin" },
{ name: "description", content: "Self-hosted flight logbook" },
];
};
export default function Index() {
return (
<div style={{ fontFamily: "system-ui, sans-serif", lineHeight: "1.8" }}>
<TailfinAppShell>
<Outlet />
</TailfinAppShell>
</div>
);
}

View File

@ -0,0 +1,52 @@
import { client } from "@/util/api";
import { useLogin } from "@/util/hooks";
import {
Box,
Button,
Group,
PasswordInput,
Stack,
TextInput,
Title,
} from "@mantine/core";
import { useForm } from "@mantine/form";
export default function Login() {
const form = useForm({
initialValues: {
username: "",
password: "",
},
});
const signInMutation = useLogin();
return (
<Stack gap="md" h="100%" justify="center" align="stretch">
<Title order={2} style={{ textAlign: "center" }}>
Login
</Title>
<Box maw={340} mx="auto">
<form
onSubmit={form.onSubmit((values) => {
signInMutation(values);
})}
>
<TextInput
label="Username"
{...form.getInputProps("username")}
mt="md"
/>
<PasswordInput
label="Password"
{...form.getInputProps("password")}
mt="md"
/>
<Group justify="center" mt="xl">
<Button type="submit">Log In</Button>
</Group>
</form>
</Box>
</Stack>
);
}

View File

@ -0,0 +1,53 @@
import {
AppShell,
Burger,
Group,
Title,
UnstyledButton,
Image,
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: "sm", 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="sm"
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 />
</AppShell.Navbar>
<AppShell.Main>{children}</AppShell.Main>
</AppShell>
);
}

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

@ -0,0 +1,68 @@
import { useMe, useSignOut } from "@/util/hooks";
import { Stack, NavLink, ActionIcon } from "@mantine/core";
import { Link, useLocation } from "@remix-run/react";
import {
IconBook2,
IconLogout,
IconMapRoute,
IconPlaneDeparture,
IconUser,
} from "@tabler/icons-react";
export default function Navbar() {
const location = useLocation();
const page = location.pathname.split("/")[2];
const me = useMe();
const signOut = useSignOut();
return (
<Stack justify="space-between" h="100%">
<Stack gap="0">
<NavLink
p="md"
component={Link}
to="/logbook"
label="Dashboard"
leftSection={<IconPlaneDeparture />}
active={page == null}
/>
<NavLink
p="md"
component={Link}
to="/logbook/flights"
label="Flights"
leftSection={<IconBook2 />}
active={page === "flights"}
/>
</Stack>
<Stack gap="0">
<NavLink
p="md"
label={
me.isError
? me.error.message
: me.isFetched
? me.data?.username
: "Not Logged In"
}
leftSection={<IconUser />}
>
<NavLink
p="md"
component={Link}
to="/logbook/me"
label="Account"
leftSection={<IconUser />}
/>
<NavLink
p="md"
onClick={() => signOut()}
label="Sign Out"
leftSection={<IconLogout />}
/>
</NavLink>
</Stack>
</Stack>
);
}

View File

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

45
web/app/util/api.ts Normal file
View File

@ -0,0 +1,45 @@
import axios from "axios";
export const client = axios.create({
baseURL: "http://localhost:8081",
headers: { "Access-Control-Allow-Origin": "*" },
});
client.interceptors.request.use(
(config) => {
const token = localStorage.getItem("token");
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
client.interceptors.request.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
if (error.response.status == 401 && !originalRequest._retry) {
originalRequest._retry = true;
const refreshToken = localStorage.getItem("refresh-token");
if (refreshToken) {
try {
const { data } = await client.post("/auth/refresh", {
refresh: refreshToken,
});
localStorage.setItem("token", data.refreshToken);
client.defaults.headers.common[
"Authorization"
] = `Bearer ${data.refreshToken}`;
return client(originalRequest);
} catch (_error) {
return Promise.reject(_error);
}
}
}
return Promise.reject(error);
}
);

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

@ -0,0 +1,52 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { client } from "./api";
import { useNavigate } from "@remix-run/react";
type User = {
username: string;
level: number;
};
export function useMe() {
return useQuery<User, Error>({
queryKey: ["me"],
queryFn: () => client.get(`/users/me`).then((res) => res.data),
});
}
export function useSignOut() {
const queryClient = useQueryClient();
const navigate = useNavigate();
const onSignOut = async () => {
queryClient.setQueryData(["user"], null);
const res = await client.post("/auth/logout");
if (res.status == 200) {
navigate("/login");
} else {
console.error("Failed to log out");
}
};
return onSignOut;
}
export function useLogin() {
const navigate = useNavigate();
const { mutate: signInMutation } = useMutation({
mutationFn: async (values) => {
return await client.postForm("/auth/login", values);
},
onSuccess: (data) => {
localStorage.setItem("token", data.data.access_token);
localStorage.setItem("refresh-token", data.data.refresh_token);
navigate("/logbook");
},
onError: (error) => {
console.error(error);
},
});
return signInMutation;
}

2404
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -11,28 +11,44 @@
"typecheck": "tsc"
},
"dependencies": {
"@mantine/core": "^7.3.2",
"@mantine/dates": "^7.3.2",
"@mantine/dropzone": "^7.3.2",
"@mantine/form": "^7.3.2",
"@mantine/hooks": "^7.3.2",
"@mantine/notifications": "^7.3.2",
"@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",
"dayjs": "^1.11.10",
"isbot": "^3.6.8",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@remix-run/dev": "^2.4.1",
"@tanstack/eslint-plugin-query": "^5.14.6",
"@types/react": "^18.2.20",
"@types/react-dom": "^18.2.7",
"@typescript-eslint/eslint-plugin": "^6.7.4",
"eslint": "^8.38.0",
"eslint-config-prettier": "^9.0.0",
"eslint-import-resolver-typescript": "^3.6.1",
"eslint-plugin-import": "^2.28.1",
"eslint-plugin-jsx-a11y": "^6.7.1",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0",
"postcss": "^8.4.32",
"postcss-preset-mantine": "^1.12.2",
"postcss-simple-vars": "^7.0.1",
"typescript": "^5.1.6"
},
"engines": {
"node": ">=18.0.0"
}
}
}

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

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

BIN
web/public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

View File

@ -1,5 +1,6 @@
/** @type {import('@remix-run/dev').AppConfig} */
export default {
postcss: true,
ignoredRouteFiles: ["**/.*"],
// appDirectory: "app",
// assetsBuildDirectory: "public/build",

View File

@ -13,7 +13,7 @@
"forceConsistentCasingInFileNames": true,
"baseUrl": ".",
"paths": {
"~/*": ["./app/*"]
"@/*": ["./app/*"]
},
// Remix takes care of building everything in `remix build`.