work-timer/app/routes/projects/new.tsx

271 lines
6.6 KiB
TypeScript

import {
Drawer,
TextInput,
useMantineTheme,
Group,
Button,
Textarea,
ColorInput,
Alert,
ActionIcon
} from '@mantine/core';
import type { ActionArgs, LoaderArgs, MetaFunction } from '@remix-run/node';
import { json, redirect } from '@remix-run/node';
import {
Form,
useActionData,
useCatch,
useLoaderData,
useNavigate
} from '@remix-run/react';
import * as React from 'react';
import { AlertTriangle, RefreshCcw, Save } from 'react-feather';
import { COLORS_MAP, randomColor } from '~/utils';
import { createProject, getProjectByName } from '~/models/project.server';
import { requireUserId } from '~/session.server';
export const meta: MetaFunction = () => {
return {
title: 'New Project | WorkTimer',
description: 'Create a new project. You must be logged in to do this.'
};
};
export async function loader({ request }: LoaderArgs) {
const userId = await requireUserId(request);
if (!userId) return redirect('/projects');
return json({});
}
export async function action({ request }: ActionArgs) {
const userId = await requireUserId(request);
const formData = await request.formData();
const name = formData.get('name');
const description = formData.get('description');
const color = formData.get('color');
if (typeof name !== 'string' || name.length === 0) {
return json(
{
errors: {
name: 'name is required',
description: null,
color: null
}
},
{ status: 400 }
);
}
if (description && typeof description !== 'string') {
return json(
{
errors: {
name: null,
description: 'Description is invalid',
color: null
}
},
{ status: 422 }
);
}
if (typeof color !== 'string' || color.length === 0) {
return json(
{
errors: {
name: null,
description: null,
color: 'color is required'
}
},
{ status: 400 }
);
}
const projectWithSameName = await getProjectByName({ name, userId });
if (projectWithSameName) {
return json(
{
errors: {
name: 'A project with this name already exists',
description: null,
color: null
}
},
{ status: 409 }
);
}
const project = await createProject({
name,
description,
color,
userId
});
return redirect(`/projects`);
}
const LayoutWrapper = ({ children }: React.PropsWithChildren<{}>) => {
const theme = useMantineTheme();
const navigate = useNavigate();
return (
<Drawer
opened
position="right"
title="New Project"
padding="xl"
size="xl"
overlayColor={
theme.colorScheme === 'dark'
? theme.colors.dark[9]
: theme.colors.gray[2]
}
overlayOpacity={0.55}
overlayBlur={3}
onClose={() => {
navigate('/projects');
}}
>
{children}
</Drawer>
);
};
export default function NewProjectPage() {
const data = useLoaderData<typeof loader>();
const actionData = useActionData<typeof action>();
const theme = useMantineTheme();
const nameRef = React.useRef<HTMLInputElement>(null);
const descriptionRef = React.useRef<HTMLTextAreaElement>(null);
const colorRef = React.useRef<HTMLInputElement>(null);
const [color, setColor] = React.useState<{
name: string;
hex: string;
}>(randomColor());
React.useEffect(() => {
if (actionData?.errors?.name) {
nameRef.current?.focus();
} else if (actionData?.errors?.description) {
descriptionRef.current?.focus();
} else if (actionData?.errors?.color) {
colorRef.current?.focus();
}
}, [actionData]);
return (
<LayoutWrapper>
<Form
method="post"
noValidate
style={{
display: 'flex',
flexDirection: 'column',
gap: 8,
width: '100%'
}}
>
<TextInput
mb={12}
withAsterisk
label="Name"
placeholder="The name of your project"
id="new-name"
ref={nameRef}
required
autoFocus={true}
name="name"
aria-invalid={actionData?.errors?.name ? true : undefined}
error={actionData?.errors?.name}
errorProps={{ children: actionData?.errors?.name }}
/>
<Textarea
mb={12}
label="Description"
placeholder="What is this project about?"
id="new-description"
ref={descriptionRef}
name="description"
aria-invalid={actionData?.errors?.description ? true : undefined}
error={actionData?.errors?.description}
errorProps={{ children: actionData?.errors?.description }}
/>
<ColorInput
label="Color"
placeholder="The color of your project"
id="new-color"
ref={colorRef}
withPicker={false}
disallowInput
withAsterisk
swatchesPerRow={6}
swatches={Object.values(COLORS_MAP)}
rightSection={
<ActionIcon onClick={() => setColor(randomColor())}>
<RefreshCcw size={16} />
</ActionIcon>
}
value={color.hex}
onChange={(value) => {
const color = Object.entries(COLORS_MAP).find(
([, hex]) => hex === value
);
if (color) {
setColor({ name: color[0], hex: color[1] });
} else {
setColor(randomColor());
}
}}
closeOnColorSwatchClick
format="hex"
required
aria-invalid={actionData?.errors?.color ? true : undefined}
error={actionData?.errors?.color}
errorProps={{ children: actionData?.errors?.color }}
/>
<input type="hidden" name="color" value={color.name} />
<Group position="left" mt="lg">
<Button type="submit" leftIcon={<Save />} radius={theme.radius.md}>
Create
</Button>
</Group>
</Form>
</LayoutWrapper>
);
}
export function ErrorBoundary({ error }: { error: Error }) {
console.error(error);
return (
<LayoutWrapper>
<Alert icon={<AlertTriangle size={14} />} title="Error" color="red">
An unexpected error occurred: {error.message}
</Alert>
</LayoutWrapper>
);
}
export function CatchBoundary() {
const caught = useCatch();
if (caught.status === 404) {
return (
<LayoutWrapper>
<Alert icon={<AlertTriangle size={14} />} title="Error" color="red">
Not found
</Alert>
</LayoutWrapper>
);
}
throw new Error(`Unexpected caught response with status: ${caught.status}`);
}