work-timer/app/root.tsx
2024-04-09 18:47:22 +03:00

564 lines
16 KiB
TypeScript

import { MetaFunction, LoaderArgs, LinksFunction } from '@remix-run/node';
import { json } from '@remix-run/node';
import {
Links,
LiveReload,
Meta,
Outlet,
Scripts,
ScrollRestoration,
Link,
useCatch,
useMatches,
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,
ThemeIcon,
NavLink,
Menu,
Badge
} from '@mantine/core';
import { useColorScheme, useLocalStorage } from '@mantine/hooks';
import { StylesPlaceholder } from '@mantine/remix';
import {
NavigationProgress,
startNavigationProgress,
completeNavigationProgress
} from '@mantine/nprogress';
import type { User as UserType } from './models/user.server';
import { getUser } from './session.server';
import {
Sun,
Moon,
Clock,
LogOut,
LogIn,
Home,
Briefcase,
BarChart2,
FileText,
Upload,
Settings,
Lock,
User,
Users
} from 'react-feather';
import { NotificationsProvider } from '@mantine/notifications';
export const meta: MetaFunction = () => ({
charset: 'utf-8',
title: 'WorkTimer',
description:
'WorkTimer is a time tracking app. Helps you track your time spent on projects.',
viewport: 'width=device-width,initial-scale=1'
});
export let links: LinksFunction = () => {
return [];
};
export async function loader({ request }: LoaderArgs) {
return json({
user: await getUser(request)
});
}
createEmotionCache({ key: 'mantine' });
export default function App() {
let transition = useTransition();
useEffect(() => {
if (transition.state === 'idle') completeNavigationProgress();
else startNavigationProgress();
}, [transition.state]);
return (
<Document>
<Layout>
<Outlet />
</Layout>
</Document>
);
}
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
});
return (
<ColorSchemeProvider
colorScheme={colorScheme}
toggleColorScheme={toggleColorScheme}
>
<MantineProvider
theme={{ colorScheme }}
withGlobalStyles
withNormalizeCSS
>
<NotificationsProvider>
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta
httpEquiv="Content-Type"
content="text/html; charset=utf-8"
/>
<meta name="application-name" content="Work Timer" />
<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" />
<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={`/favicon-32x32.png`}
/>
<link
rel="icon"
type="image/png"
sizes="16x16"
href={`/favicon-32x32.png`}
/>
<link
rel="icon"
type="image/png"
sizes="512x512"
href={`/android-chrome-512x512.png`}
/>
<link
rel="icon"
type="image/png"
sizes="192x192"
href={`/android-chrome-192x192.png`}
/>
{title ? <title>{title}</title> : null}
<StylesPlaceholder />
<Meta />
<Links />
</head>
<body>
<NavigationProgress />
{children}
<ScrollRestoration />
<Scripts />
{process.env.NODE_ENV === 'development' && <LiveReload />}
</body>
</html>
</NotificationsProvider>
</MantineProvider>
</ColorSchemeProvider>
);
}
export function CatchBoundary() {
let caught = useCatch();
let message;
switch (caught.status) {
case 401:
message =
'Oops! Looks like you tried to visit a page that you do not have access to.';
break;
case 404:
message =
'Oops! Looks like you tried to visit a page that does not exist.';
break;
default:
throw new Error(caught.data || caught.statusText);
}
return (
<Document title={`${caught.status} ${caught.statusText}`}>
<Layout>
<div>
<h1>
{caught.status}: {caught.statusText}
</h1>
<p>{message}</p>
</div>
</Layout>
</Document>
);
}
function Layout({ children }: React.PropsWithChildren<{}>) {
let user = useMatches().find((m) => m.id === 'root')?.data?.user as
| UserType
| undefined;
const [opened, setOpened] = useState(false);
const location = useLocation();
const theme = useMantineTheme();
const { colorScheme, toggleColorScheme } = useMantineColorScheme();
useEffect(() => {
setOpened(false);
}, [location]);
return (
<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="left" align="center">
<ActionIcon
variant="default"
onClick={() => toggleColorScheme()}
size={30}
title="Toggle color scheme"
>
{colorScheme === 'dark' ? (
<Sun size={16} />
) : (
<Moon size={16} />
)}
</ActionIcon>
<Text size="sm">Toggle theme</Text>
</Group>
</Box>
</Navbar.Section>
</MediaQuery>
{user && (
<Navbar.Section mt="md" grow={!user.admin}>
<NavLink
component={Link}
to="/"
label="Home"
icon={
<ThemeIcon variant="light">
<Home size={16} />
</ThemeIcon>
}
active={location.pathname === '/'}
/>
<NavLink
component={Link}
to="/time-entries"
label="Time entries"
icon={
<ThemeIcon variant="light">
<Clock size={16} />
</ThemeIcon>
}
variant="light"
active={location.pathname.includes('/time-entries')}
/>
<NavLink
component={Link}
to="/projects"
label="Projects"
icon={
<ThemeIcon variant="light">
<Briefcase size={16} />
</ThemeIcon>
}
variant="light"
active={location.pathname.includes('/projects')}
/>
<NavLink
component={Link}
to="/reports"
label="Reports"
icon={
<ThemeIcon variant="light">
<BarChart2 size={16} />
</ThemeIcon>
}
variant="light"
active={location.pathname.includes('/report')}
/>
<NavLink
component={Link}
to="/importexport"
label="Import/Export"
icon={
<ThemeIcon variant="light">
<Upload size={16} />
</ThemeIcon>
}
variant="light"
active={location.pathname.includes('/importexport')}
/>
</Navbar.Section>
)}
{!!user?.admin && (
<Navbar.Section
grow
sx={{
marginTop: 16,
paddingTop: 16,
borderTop: `1px solid ${
theme.colorScheme === 'dark'
? theme.colors.dark[4]
: theme.colors.gray[2]
}`
}}
>
<Badge variant="light">ADMIN</Badge>
<NavLink
component={Link}
to="/users"
label="Users"
icon={
<ThemeIcon variant="light">
<Users size={16} />
</ThemeIcon>
}
variant="light"
active={location.pathname.includes('/users')}
/>
<NavLink
component={Link}
to="/settings"
label="Settings"
icon={
<ThemeIcon variant="light">
<Settings size={16} />
</ThemeIcon>
}
variant="light"
active={location.pathname.includes('/settings')}
/>
</Navbar.Section>
)}
{user && (
<Navbar.Section mt="lg">
<Menu shadow="md" width={200}>
<Menu.Target>
<UnstyledButton w="100%" title="Account / Logout" p="xs">
<Group w="100%">
<ThemeIcon
variant="light"
sx={{
flexShrink: 1
}}
>
<User size={16} />
</ThemeIcon>
<div>
<Text
sx={{
flex: 1
}}
>
{user.email.split('@')[0]}
</Text>
<Text size="xs" color="dimmed">
{user.email.split('@')[1]}
</Text>
</div>
</Group>
</UnstyledButton>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>Account</Menu.Label>
<Menu.Item
icon={<Settings size={14} />}
component={Link}
to="/account"
>
Settings
</Menu.Item>
<Form action="/logout" method="post" noValidate>
<Menu.Item icon={<LogOut size={14} />} type="submit">
Logout
</Menu.Item>
</Form>
</Menu.Dropdown>
</Menu>
</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
style={{
fontWeight: 700,
lineHeight: 1.2,
fontSize: '1.25rem'
}}
>
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"
>
Sign in
</Button>
)}
</div>
</div>
</Header>
}
styles={(theme) => ({
main: {
backgroundColor:
theme.colorScheme === 'dark'
? theme.colors.dark[8]
: theme.colors.gray[0]
}
})}
>
{children}
</AppShell>
);
}
export function ErrorBoundary({ error }: { error: Error }) {
console.error(error);
return (
<Document title="Error!">
<Layout>
<div>
<h1>There was an error</h1>
<p>{error.message}</p>
<hr />
<p>
Hey, developer, you should replace this with what you want your
users to see.
</p>
</div>
</Layout>
</Document>
);
}