work-timer/app/root.tsx

477 lines
13 KiB
TypeScript
Raw Normal View History

2023-02-14 10:14:28 +01:00
import { MetaFunction, LoaderArgs, LinksFunction } from '@remix-run/node';
import { json } from '@remix-run/node';
2023-02-11 03:14:14 +01:00
import {
Links,
LiveReload,
Meta,
Outlet,
Scripts,
ScrollRestoration,
2023-02-14 10:14:28 +01:00
Link,
2023-02-11 03:14:14 +01:00
useCatch,
useMatches,
2023-02-14 10:14:28 +01:00
useTransition,
Form,
useLocation
} from '@remix-run/react';
import { useEffect, useState } from 'react';
import {
MantineProvider,
createEmotionCache,
useMantineTheme,
ColorScheme,
ColorSchemeProvider,
AppShell,
Navbar,
Header,
Text,
MediaQuery,
Burger,
ActionIcon,
useMantineColorScheme,
Button,
Box,
Group,
UnstyledButton,
2023-02-18 21:54:57 +01:00
ThemeIcon,
NavLink
2023-02-14 10:14:28 +01:00
} from '@mantine/core';
import { useColorScheme, useLocalStorage } from '@mantine/hooks';
import { StylesPlaceholder } from '@mantine/remix';
import {
NavigationProgress,
startNavigationProgress,
completeNavigationProgress
} from '@mantine/nprogress';
import { getUser } from './session.server';
import {
Sun,
Moon,
Clock,
LogOut,
LogIn,
Home,
Briefcase,
BarChart2,
FileText,
Upload
} from 'react-feather';
import { NotificationsProvider } from '@mantine/notifications';
2023-02-11 03:14:14 +01:00
export const meta: MetaFunction = () => ({
charset: 'utf-8',
2023-02-14 10:14:28 +01:00
title: 'WorkTimer',
description:
'WorkTimer is a time tracking app. Helps you track your time spent on projects.',
2023-02-11 03:14:14 +01:00
viewport: 'width=device-width,initial-scale=1'
2023-02-14 10:14:28 +01:00
});
2023-02-11 03:14:14 +01:00
export let links: LinksFunction = () => {
2023-02-14 10:14:28 +01:00
return [];
};
2023-02-11 03:14:14 +01:00
export async function loader({ request }: LoaderArgs) {
return json({
user: await getUser(request)
2023-02-14 10:14:28 +01:00
});
2023-02-11 03:14:14 +01:00
}
2023-02-14 10:14:28 +01:00
createEmotionCache({ key: 'mantine' });
2023-02-11 03:14:14 +01:00
export default function App() {
2023-02-14 10:14:28 +01:00
let transition = useTransition();
2023-02-11 03:14:14 +01:00
useEffect(() => {
2023-02-14 10:14:28 +01:00
if (transition.state === 'idle') completeNavigationProgress();
else startNavigationProgress();
}, [transition.state]);
2023-02-11 03:14:14 +01:00
return (
<Document>
<Layout>
<Outlet />
</Layout>
</Document>
2023-02-14 10:14:28 +01:00
);
2023-02-11 03:14:14 +01:00
}
2023-02-14 10:14:28 +01:00
function Document({
children,
title
}: {
children: React.ReactNode;
title?: string;
}) {
const preferredColorScheme = useColorScheme();
const toggleColorScheme = (value?: ColorScheme) => {
setColorScheme(value || (colorScheme === 'dark' ? 'light' : 'dark'));
};
const [colorScheme, setColorScheme] = useLocalStorage<ColorScheme>({
key: 'mantine-color-scheme',
defaultValue: preferredColorScheme,
getInitialValueInEffect: true
});
2023-02-11 03:14:14 +01:00
return (
2023-02-14 10:14:28 +01:00
<ColorSchemeProvider
colorScheme={colorScheme}
toggleColorScheme={toggleColorScheme}
>
<MantineProvider
theme={{ colorScheme }}
withGlobalStyles
withNormalizeCSS
>
<NotificationsProvider>
<html lang="en">
<head>
<meta charSet="utf-8" />
2023-02-20 15:00:29 +01:00
<meta
httpEquiv="Content-Type"
content="text/html; charset=utf-8"
/>
<meta name="application-name" content="Work Timer" />
2023-02-18 20:37:37 +01:00
<meta
name="viewport"
content="minimum-scale=1, initial-scale=1, width=device-width, shrink-to-fit=no, user-scalable=no, viewport-fit=cover"
/>
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta
name="apple-mobile-web-app-status-bar-style"
content="default"
/>
<meta name="format-detection" content="telephone=no" />
<meta name="mobile-web-app-capable" content="yes" />
2023-02-20 15:00:29 +01:00
<link rel="icon" href="/favicon.ico" />
<link
rel="apple-touch-icon"
sizes="180x180"
href={`/apple-touch-icon.png`}
/>
<link
rel="icon"
type="image/png"
sizes="32x32"
href={`/images/favicon-32x32.png`}
/>
<link
rel="icon"
type="image/png"
sizes="16x16"
href={`/images/favicon-32x32.png`}
/>
<link
rel="icon"
type="image/png"
sizes="512x512"
href={`/images/android-chrome-512x512.png`}
/>
<link
rel="icon"
type="image/png"
sizes="192x192"
href={`/images/android-chrome-192x192.png`}
/>
2023-02-14 10:14:28 +01:00
{title ? <title>{title}</title> : null}
2023-02-20 15:00:29 +01:00
2023-02-14 10:14:28 +01:00
<StylesPlaceholder />
<Meta />
<Links />
</head>
<body>
<NavigationProgress />
{children}
<ScrollRestoration />
<Scripts />
{process.env.NODE_ENV === 'development' && <LiveReload />}
</body>
</html>
</NotificationsProvider>
</MantineProvider>
</ColorSchemeProvider>
);
2023-02-11 03:14:14 +01:00
}
export function CatchBoundary() {
2023-02-14 10:14:28 +01:00
let caught = useCatch();
2023-02-11 03:14:14 +01:00
2023-02-14 10:14:28 +01:00
let message;
2023-02-11 03:14:14 +01:00
switch (caught.status) {
case 401:
2023-02-14 10:14:28 +01:00
message =
'Oops! Looks like you tried to visit a page that you do not have access to.';
2023-02-11 03:14:14 +01:00
2023-02-14 10:14:28 +01:00
break;
2023-02-11 03:14:14 +01:00
case 404:
2023-02-14 10:14:28 +01:00
message =
'Oops! Looks like you tried to visit a page that does not exist.';
2023-02-11 03:14:14 +01:00
2023-02-14 10:14:28 +01:00
break;
2023-02-11 03:14:14 +01:00
default:
2023-02-14 10:14:28 +01:00
throw new Error(caught.data || caught.statusText);
2023-02-11 03:14:14 +01:00
}
return (
<Document title={`${caught.status} ${caught.statusText}`}>
<Layout>
<div>
<h1>
{caught.status}: {caught.statusText}
</h1>
<p>{message}</p>
</div>
</Layout>
</Document>
2023-02-14 10:14:28 +01:00
);
2023-02-11 03:14:14 +01:00
}
function Layout({ children }: React.PropsWithChildren<{}>) {
2023-02-14 10:14:28 +01:00
let user = useMatches().find((m) => m.id === 'root')?.data?.user;
const [opened, setOpened] = useState(false);
const location = useLocation();
const theme = useMantineTheme();
const { colorScheme, toggleColorScheme } = useMantineColorScheme();
useEffect(() => {
setOpened(false);
}, [location]);
2023-02-11 03:14:14 +01:00
return (
2023-02-14 10:14:28 +01:00
<AppShell
padding="md"
navbarOffsetBreakpoint="sm"
asideOffsetBreakpoint="sm"
navbar={
user && (
<Navbar
p="xs"
hiddenBreakpoint="sm"
hidden={!opened}
width={{ sm: 200, lg: 250 }}
>
<MediaQuery largerThan="sm" styles={{ display: 'none' }}>
<Navbar.Section>
<Box
sx={(theme) => ({
paddingLeft: theme.spacing.xs,
paddingRight: theme.spacing.xs,
paddingBottom: theme.spacing.lg,
borderBottom: `1px solid ${
theme.colorScheme === 'dark'
? theme.colors.dark[4]
: theme.colors.gray[2]
}`
})}
>
<Group position="apart" align="center">
<ActionIcon
variant="default"
onClick={() => toggleColorScheme()}
size={30}
title="Toggle color scheme"
>
{colorScheme === 'dark' ? (
<Sun size={16} />
) : (
<Moon size={16} />
)}
</ActionIcon>
</Group>
</Box>
</Navbar.Section>
</MediaQuery>
{user && (
<Navbar.Section grow mt="md">
2023-02-18 21:54:57 +01:00
<NavLink
2023-02-14 10:14:28 +01:00
component={Link}
to="/"
2023-02-18 21:54:57 +01:00
label="Home"
icon={
2023-02-14 10:14:28 +01:00
<ThemeIcon variant="light">
<Home size={16} />
</ThemeIcon>
2023-02-18 21:54:57 +01:00
}
active={location.pathname === '/'}
/>
<NavLink
2023-02-14 10:14:28 +01:00
component={Link}
to="/time-entries"
2023-02-18 21:54:57 +01:00
label="Time entries"
icon={
2023-02-14 10:14:28 +01:00
<ThemeIcon variant="light">
<Clock size={16} />
</ThemeIcon>
2023-02-18 21:54:57 +01:00
}
variant="light"
active={location.pathname.includes('/time-entries')}
/>
<NavLink
2023-02-14 10:14:28 +01:00
component={Link}
to="/projects"
2023-02-18 21:54:57 +01:00
label="Projects"
icon={
2023-02-14 10:14:28 +01:00
<ThemeIcon variant="light">
<Briefcase size={16} />
</ThemeIcon>
2023-02-18 21:54:57 +01:00
}
variant="light"
active={location.pathname.includes('/projects')}
/>
<NavLink
2023-02-14 10:14:28 +01:00
component={Link}
to="/reports"
label="Reports"
2023-02-18 21:54:57 +01:00
icon={
2023-02-14 10:14:28 +01:00
<ThemeIcon variant="light">
<BarChart2 size={16} />
2023-02-14 10:14:28 +01:00
</ThemeIcon>
2023-02-18 21:54:57 +01:00
}
variant="light"
active={location.pathname.includes('/report')}
/>
<NavLink
2023-02-14 10:14:28 +01:00
component={Link}
2023-02-18 20:37:37 +01:00
to="/importexport"
2023-02-18 21:54:57 +01:00
label="Import/Export"
icon={
2023-02-14 10:14:28 +01:00
<ThemeIcon variant="light">
<Upload size={16} />
</ThemeIcon>
2023-02-18 21:54:57 +01:00
}
variant="light"
active={location.pathname.includes('/importexport')}
/>
2023-02-14 10:14:28 +01:00
</Navbar.Section>
)}
{user && (
<Navbar.Section>
<Form action="/logout" method="post">
<UnstyledButton type="submit" ml="sm" title="Logout">
<Group>
<ThemeIcon variant="light">
<LogOut size={16} />
</ThemeIcon>
<div>
<Text>{user.email.split('@')[0]}</Text>
<Text size="xs" color="dimmed">
Click to logout
</Text>
</div>
</Group>
</UnstyledButton>
</Form>
</Navbar.Section>
)}
</Navbar>
)
}
header={
<Header height={{ base: 50, md: 70 }} p="md">
<div
style={{ display: 'flex', alignItems: 'center', height: '100%' }}
>
<MediaQuery largerThan="sm" styles={{ display: 'none' }}>
<Burger
opened={opened}
onClick={() => setOpened((o) => !o)}
size="sm"
color={theme.colors.gray[6]}
mr="xl"
/>
</MediaQuery>
<Text
component={Link}
to="/"
style={{
display: 'flex',
alignItems: 'center'
}}
>
<ThemeIcon
variant="light"
size={24}
style={{ marginRight: theme.spacing.sm }}
>
<Clock />
</ThemeIcon>
<span>WorkTimer</span>
</Text>
<div
style={{
display: 'flex',
alignItems: 'center',
marginLeft: 'auto'
}}
>
<MediaQuery
smallerThan="sm"
styles={user ? { display: 'none' } : {}}
>
<ActionIcon
variant="light"
onClick={() =>
toggleColorScheme(colorScheme === 'dark' ? 'light' : 'dark')
}
title="Toggle color scheme"
>
{colorScheme === 'dark' ? (
<Sun size={18} />
) : (
<Moon size={18} />
)}
</ActionIcon>
</MediaQuery>
{!user && (
<Button
variant="light"
ml="sm"
leftIcon={<LogIn />}
component={Link}
to="/login"
>
Login
</Button>
)}
</div>
</div>
</Header>
}
styles={(theme) => ({
main: {
backgroundColor:
theme.colorScheme === 'dark'
? theme.colors.dark[8]
: theme.colors.gray[0]
}
})}
>
{children}
</AppShell>
);
2023-02-11 03:14:14 +01:00
}
export function ErrorBoundary({ error }: { error: Error }) {
2023-02-14 10:14:28 +01:00
console.error(error);
2023-02-11 03:14:14 +01:00
return (
<Document title="Error!">
<Layout>
<div>
<h1>There was an error</h1>
<p>{error.message}</p>
<hr />
2023-02-14 10:14:28 +01:00
<p>
Hey, developer, you should replace this with what you want your
users to see.
</p>
2023-02-11 03:14:14 +01:00
</div>
</Layout>
</Document>
2023-02-14 10:14:28 +01:00
);
2023-02-11 03:14:14 +01:00
}