feat: add projects management
This commit is contained in:
parent
9b16fe617c
commit
e0843fc89a
240
app/routes/projects.tsx
Normal file
240
app/routes/projects.tsx
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
import { useMemo } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Paper,
|
||||
Text,
|
||||
Menu,
|
||||
ActionIcon,
|
||||
Pagination,
|
||||
NativeSelect,
|
||||
Group,
|
||||
useMantineTheme,
|
||||
Alert,
|
||||
ColorSwatch
|
||||
} from '@mantine/core';
|
||||
import { json, LoaderArgs, MetaFunction, redirect } from '@remix-run/node';
|
||||
import {
|
||||
Form,
|
||||
Link,
|
||||
Outlet,
|
||||
useCatch,
|
||||
useLoaderData,
|
||||
useSearchParams
|
||||
} from '@remix-run/react';
|
||||
import { AlertTriangle, Edit3, Plus, Settings, Trash } from 'react-feather';
|
||||
import { requireUserId } from '~/session.server';
|
||||
import { getProjects } from '~/models/project.server';
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return {
|
||||
title: 'Projects | WorkTimer',
|
||||
description: 'Manage your projects. You must be logged in to do this.'
|
||||
};
|
||||
};
|
||||
|
||||
export async function loader({ request }: LoaderArgs) {
|
||||
const userId = await requireUserId(request);
|
||||
if (!userId) return redirect('/login');
|
||||
|
||||
const url = new URL(request.url);
|
||||
const page = url.searchParams.get('page')
|
||||
? parseInt(url.searchParams.get('page')!, 10)
|
||||
: 1;
|
||||
const size = url.searchParams.get('size')
|
||||
? parseInt(url.searchParams.get('size')!, 10)
|
||||
: 25;
|
||||
const orderBy = url.searchParams.get('orderBy') || 'createdAt';
|
||||
const order = url.searchParams.get('order') || 'desc';
|
||||
|
||||
return json({
|
||||
...(await getProjects({
|
||||
page,
|
||||
size,
|
||||
userId,
|
||||
orderBy,
|
||||
order: order === 'asc' ? 'asc' : 'desc'
|
||||
}))
|
||||
});
|
||||
}
|
||||
|
||||
export default function Projects() {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const theme = useMantineTheme();
|
||||
|
||||
const pageSize = useMemo(() => {
|
||||
return parseInt(searchParams.get('size') || '25', 10);
|
||||
}, [searchParams]);
|
||||
const page = useMemo(() => {
|
||||
return parseInt(searchParams.get('page') || '1', 10);
|
||||
}, [searchParams]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Paper
|
||||
component="fieldset"
|
||||
aria-controls="projects"
|
||||
p="sm"
|
||||
shadow="sm"
|
||||
radius="md"
|
||||
withBorder
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
flexWrap: 'wrap'
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
component={Link}
|
||||
to="/projects/new"
|
||||
variant="light"
|
||||
radius={theme.radius.md}
|
||||
leftIcon={<Plus />}
|
||||
>
|
||||
New project
|
||||
</Button>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-end',
|
||||
justifyContent: 'space-between'
|
||||
}}
|
||||
>
|
||||
<NativeSelect
|
||||
data={[
|
||||
{ label: '25 / page', value: '25' },
|
||||
{ label: '50 / page', value: '50' },
|
||||
{ label: '100 / page', value: '100' }
|
||||
]}
|
||||
value={pageSize}
|
||||
onChange={(event) => {
|
||||
setSearchParams({
|
||||
page: page.toString(),
|
||||
size: event.currentTarget.value
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{data.total / pageSize > 1 && (
|
||||
<Pagination
|
||||
style={{ marginLeft: 10 }}
|
||||
page={page}
|
||||
total={Math.ceil(data.total / pageSize)}
|
||||
onChange={(page) => {
|
||||
setSearchParams({
|
||||
page: page.toString(),
|
||||
size: pageSize.toString()
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Paper>
|
||||
<Group mt="lg" mb="md" mx="auto">
|
||||
<Text size="sm" color="darkgray">
|
||||
{data.total} entries
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<div role="region" id="projects">
|
||||
{data.projects.map((project) => (
|
||||
<Paper
|
||||
key={project.id}
|
||||
shadow="sm"
|
||||
p="md"
|
||||
radius="md"
|
||||
mb="sm"
|
||||
display="flex"
|
||||
style={{
|
||||
alignItems: 'center',
|
||||
flexDirection: 'column'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
width: '100%'
|
||||
}}
|
||||
>
|
||||
<ColorSwatch color={project.color} />
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'column',
|
||||
marginRight: 'auto',
|
||||
marginLeft: '1rem'
|
||||
}}
|
||||
>
|
||||
<strong>{project.name}</strong>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '0.8em'
|
||||
}}
|
||||
>
|
||||
{project.description}
|
||||
</span>
|
||||
</div>
|
||||
<Menu shadow="md" width={200}>
|
||||
<Menu.Target>
|
||||
<ActionIcon title="Edit" mr="xs">
|
||||
<Settings size={14} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
|
||||
<Menu.Dropdown>
|
||||
<Menu.Label>Edit project</Menu.Label>
|
||||
<Menu.Item
|
||||
component={Link}
|
||||
to={`/projects/${project.id}`}
|
||||
icon={<Edit3 size={14} color={theme.colors.yellow[8]} />}
|
||||
>
|
||||
Edit
|
||||
</Menu.Item>
|
||||
<Form method="delete" action={`/projects/${project.id}`}>
|
||||
<Menu.Item
|
||||
component="button"
|
||||
type="submit"
|
||||
icon={<Trash size={14} color={theme.colors.red[8]} />}
|
||||
>
|
||||
Delete
|
||||
</Menu.Item>
|
||||
</Form>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</div>
|
||||
</Paper>
|
||||
))}
|
||||
</div>
|
||||
<Outlet />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorBoundary({ error }: { error: Error }) {
|
||||
console.error(error);
|
||||
|
||||
return (
|
||||
<Alert icon={<AlertTriangle size={14} />} title="Error" color="red">
|
||||
An unexpected error occurred: {error.message}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
export function CatchBoundary() {
|
||||
const caught = useCatch();
|
||||
|
||||
if (caught.status === 404) {
|
||||
return (
|
||||
<Alert icon={<AlertTriangle size={14} />} title="Error" color="red">
|
||||
Not found
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected caught response with status: ${caught.status}`);
|
||||
}
|
||||
274
app/routes/projects/$projectId.tsx
Normal file
274
app/routes/projects/$projectId.tsx
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
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 {
|
||||
Alert,
|
||||
Drawer,
|
||||
TextInput,
|
||||
Text,
|
||||
useMantineTheme,
|
||||
Group,
|
||||
Button,
|
||||
Textarea,
|
||||
Stack,
|
||||
Select,
|
||||
ColorSwatch,
|
||||
ColorInput,
|
||||
ActionIcon,
|
||||
Input,
|
||||
ColorPicker
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Delete,
|
||||
Play,
|
||||
RefreshCcw,
|
||||
Save,
|
||||
Square,
|
||||
Trash
|
||||
} from 'react-feather';
|
||||
import invariant from 'tiny-invariant';
|
||||
|
||||
import {
|
||||
deleteTimeEntry,
|
||||
getTimeEntry,
|
||||
updateTimeEntry
|
||||
} from '~/models/timeEntry.server';
|
||||
import { requireUserId } from '~/session.server';
|
||||
import {
|
||||
deleteProject,
|
||||
getProject,
|
||||
getProjects,
|
||||
Project,
|
||||
updateProject
|
||||
} from '~/models/project.server';
|
||||
import { DatePicker, TimeInput } from '@mantine/dates';
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return {
|
||||
title: 'Edit Project | WorkTimer',
|
||||
description: 'Edit a project. You must be logged in to do this.'
|
||||
};
|
||||
};
|
||||
|
||||
export async function loader({ request, params }: LoaderArgs) {
|
||||
const userId = await requireUserId(request);
|
||||
invariant(params.projectId, 'projectId not found');
|
||||
|
||||
const project = await getProject({ userId, id: params.projectId });
|
||||
if (!project) {
|
||||
throw new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
return json({ project });
|
||||
}
|
||||
|
||||
export async function action({ request, params }: ActionArgs) {
|
||||
const userId = await requireUserId(request);
|
||||
invariant(params.projectId, 'projectId not found');
|
||||
|
||||
const project = await getProject({ userId, id: params.projectId });
|
||||
if (!project) {
|
||||
throw new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
if (request.method === 'DELETE') {
|
||||
await deleteProject({ userId, id: params.projectId });
|
||||
} else if (request.method === 'PATCH') {
|
||||
const formData = await request.formData();
|
||||
|
||||
const name = (formData.get('name') || undefined) as string | undefined;
|
||||
const description = (formData.get('description') || undefined) as
|
||||
| string
|
||||
| undefined;
|
||||
let color = (formData.get('color') || undefined) as string | undefined;
|
||||
|
||||
await updateProject({
|
||||
projectId: params.projectId,
|
||||
name,
|
||||
description,
|
||||
color
|
||||
});
|
||||
}
|
||||
|
||||
return redirect('/projects');
|
||||
}
|
||||
|
||||
const LayoutWrapper = ({ children }: React.PropsWithChildren<{}>) => {
|
||||
const theme = useMantineTheme();
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
opened
|
||||
position="right"
|
||||
title="Edit 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>
|
||||
);
|
||||
};
|
||||
|
||||
const randomColor = () =>
|
||||
`#${Math.floor(Math.random() * 16777215).toString(16)}`;
|
||||
|
||||
export default function ProjectDetailsPage() {
|
||||
const actionData = useActionData<typeof action>();
|
||||
const data = useLoaderData<typeof loader>();
|
||||
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<Project['color']>(
|
||||
data.project.color || randomColor()
|
||||
);
|
||||
|
||||
return (
|
||||
<LayoutWrapper>
|
||||
<Form
|
||||
method="patch"
|
||||
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"
|
||||
defaultValue={data.project.name}
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
mb={12}
|
||||
label="Description"
|
||||
placeholder="What is this project about?"
|
||||
id="new-description"
|
||||
ref={descriptionRef}
|
||||
name="description"
|
||||
defaultValue={data.project.description || undefined}
|
||||
/>
|
||||
|
||||
<Input.Wrapper
|
||||
label="Color"
|
||||
placeholder="The color of your project"
|
||||
id="new-color"
|
||||
withAsterisk
|
||||
required
|
||||
>
|
||||
<Group>
|
||||
<ColorSwatch color={color} size={40} />
|
||||
<Group
|
||||
style={{
|
||||
gap: 8,
|
||||
maxWidth: 200
|
||||
}}
|
||||
>
|
||||
{Object.keys(theme.colors)
|
||||
.filter((c) => c !== 'dark' && c !== 'grape')
|
||||
.map((c) => (
|
||||
<ColorSwatch
|
||||
color={c}
|
||||
key={c}
|
||||
onClick={() => {
|
||||
setColor(c);
|
||||
}}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
border: `1px solid ${
|
||||
c === color ? 'white' : 'transparent'
|
||||
}`
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Group>
|
||||
</Group>
|
||||
<input type="hidden" ref={colorRef} name="color" value={color} />
|
||||
</Input.Wrapper>
|
||||
|
||||
<Group position="left" mt="lg">
|
||||
<Button type="submit" leftIcon={<Save />} radius={theme.radius.md}>
|
||||
Save
|
||||
</Button>
|
||||
</Group>
|
||||
</Form>
|
||||
|
||||
<section style={{ marginTop: '1rem', marginBottom: '1rem' }}>
|
||||
<Form method="delete">
|
||||
<input
|
||||
type="hidden"
|
||||
name="endTime"
|
||||
value={new Date().toISOString()}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
color="red"
|
||||
leftIcon={<Trash />}
|
||||
radius={theme.radius.md}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</Form>
|
||||
</section>
|
||||
</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}`);
|
||||
}
|
||||
248
app/routes/projects/new.tsx
Normal file
248
app/routes/projects/new.tsx
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
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 { createProject, Project } 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 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>
|
||||
);
|
||||
};
|
||||
|
||||
const randomColor = () =>
|
||||
`#${Math.floor(Math.random() * 16777215).toString(16)}`;
|
||||
|
||||
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<Project['color']>(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"
|
||||
name="color"
|
||||
ref={colorRef}
|
||||
withPicker={false}
|
||||
withEyeDropper
|
||||
withAsterisk
|
||||
swatchesPerRow={6}
|
||||
swatches={Object.keys(theme.colors).map(
|
||||
(color) => theme.colors[color][6]
|
||||
)}
|
||||
rightSection={
|
||||
<ActionIcon onClick={() => setColor(randomColor())}>
|
||||
<RefreshCcw size={16} />
|
||||
</ActionIcon>
|
||||
}
|
||||
value={color}
|
||||
onChange={setColor}
|
||||
closeOnColorSwatchClick
|
||||
format="hex"
|
||||
required
|
||||
aria-invalid={actionData?.errors?.color ? true : undefined}
|
||||
error={actionData?.errors?.color}
|
||||
errorProps={{ children: actionData?.errors?.color }}
|
||||
/>
|
||||
|
||||
<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}`);
|
||||
}
|
||||
Loading…
Reference in a new issue