feat: implement frontend login functionality with form handling and error management
This commit is contained in:
139
apps/frontend/app/routes/auth/login.tsx
Normal file
139
apps/frontend/app/routes/auth/login.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { Box, Container, Flex, Heading } from '@radix-ui/themes';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { Slide, toast, ToastContainer } from 'react-toastify';
|
||||
import * as v from 'valibot';
|
||||
import { useResponseErrorHandler } from '../../hooks/ResponseHelper';
|
||||
import { useApi } from '../../providers/ApiProvider';
|
||||
import { formHook } from '../../providers/FormProvider';
|
||||
import type { Route } from './+types/login';
|
||||
|
||||
const loginFormSchema = v.object({
|
||||
username: v.pipe(v.string(), v.trim(), v.minLength(1, 'Username is required')),
|
||||
password: v.pipe(v.string(), v.minLength(1, 'Password is required')),
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-empty-pattern
|
||||
export function meta({}: Route.MetaArgs): Route.MetaDescriptors {
|
||||
return [{ title: 'Login | YANPM' }];
|
||||
}
|
||||
|
||||
export default function LoginRoute() {
|
||||
const navigate = useNavigate();
|
||||
const { tanstackApiClient } = useApi();
|
||||
const { defaultResponseErrorHandler } = useResponseErrorHandler();
|
||||
|
||||
const { mutateAsync: login, isPending } = useMutation({
|
||||
...tanstackApiClient.mutation('post', '/api/auth/login').mutationOptions,
|
||||
onSuccess: async () => {
|
||||
navigate('/');
|
||||
},
|
||||
onError: (error) => {
|
||||
if (defaultResponseErrorHandler(error)) return;
|
||||
console.error('Login failed:', error);
|
||||
},
|
||||
});
|
||||
|
||||
const form = formHook.useAppForm({
|
||||
defaultValues: {
|
||||
username: '',
|
||||
password: '',
|
||||
},
|
||||
validators: {
|
||||
onBlur: loginFormSchema,
|
||||
onSubmit: loginFormSchema,
|
||||
},
|
||||
|
||||
onSubmit: async ({ value }) => {
|
||||
toast.dismiss();
|
||||
return await login({ body: { password: value.password, username: value.username } });
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Flex align="center" justify="center" style={{ minHeight: 'calc(100vh - 64px)' }}>
|
||||
<Container size="3" p="0">
|
||||
<Box
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
maxWidth: 420,
|
||||
margin: '40px auto',
|
||||
backgroundColor: 'white',
|
||||
padding: 24,
|
||||
borderRadius: 8,
|
||||
boxShadow: '0 6px 18px rgba(15,23,42,0.2)',
|
||||
}}
|
||||
>
|
||||
<Heading size="6" style={{ marginBottom: 16, alignSelf: 'center' }}>
|
||||
Sign In
|
||||
</Heading>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<form.AppField
|
||||
name="username"
|
||||
children={(field) => (
|
||||
<>
|
||||
<field.TextField
|
||||
label={'Username'}
|
||||
value={field.state.value}
|
||||
autoComplete="username"
|
||||
spellCheck={false}
|
||||
required
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
/>
|
||||
<field.TextFieldErrorMessage {...field.state.meta} />
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
|
||||
<form.AppField
|
||||
name="password"
|
||||
children={(field) => (
|
||||
<>
|
||||
<field.TextField
|
||||
label={'Password'}
|
||||
value={field.state.value}
|
||||
type="password"
|
||||
required
|
||||
autoComplete="current-password"
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
showPasswordToggle
|
||||
/>
|
||||
<field.TextFieldErrorMessage {...field.state.meta} />
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div style={{ marginTop: 18, display: 'flex', gap: 8 }}>
|
||||
<form.SubmitButton
|
||||
loading={isPending}
|
||||
label={{
|
||||
default: 'Sign In',
|
||||
loading: 'Signing In…',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</Box>
|
||||
</Container>
|
||||
</Flex>
|
||||
<ToastContainer
|
||||
position="top-center"
|
||||
autoClose={false}
|
||||
newestOnTop={false}
|
||||
closeOnClick
|
||||
rtl={false}
|
||||
pauseOnFocusLoss
|
||||
draggable={false}
|
||||
theme="colored"
|
||||
transition={Slide}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,73 @@
|
||||
import { Text } from '@radix-ui/themes';
|
||||
import { Box, Button, Card, Flex, Grid, Heading, Text } from '@radix-ui/themes';
|
||||
import type { Route } from './+types/home';
|
||||
import { useContext } from 'react';
|
||||
import { useApi } from '../providers/ApiProvider';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import TablePlaceholder from '../components/home/TablePlaceholder';
|
||||
import { useLayout } from '../providers/LayoutProvider';
|
||||
|
||||
// eslint-disable-next-line no-empty-pattern
|
||||
export function meta({}: Route.MetaArgs) {
|
||||
return [{ title: 'YANPM' }, { name: 'description', content: 'Welcome to Yet Another Nginx Proxy Manager!' }];
|
||||
return [{ title: 'Proxy Host Demo | YANPM' }, { name: 'description', content: 'Demo of the unified navigation paradigm.' }];
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
return <Text>Welcome to Yet Another Nginx Proxy Manager!</Text>;
|
||||
export default function ProxyHostDemo() {
|
||||
const { activeTab } = useLayout();
|
||||
return (
|
||||
<Box>
|
||||
<Heading size="7" mb="1">
|
||||
{activeTab}
|
||||
</Heading>
|
||||
<Text color="gray" mb="4" as="p">
|
||||
This is the {activeTab.toLowerCase()} page demo.
|
||||
</Text>
|
||||
|
||||
<Grid columns={{ initial: '1', sm: '2', lg: '3' }} gap="4">
|
||||
<Card size="2">
|
||||
<Flex direction="column" gap="2">
|
||||
<Text size="2" weight="bold">
|
||||
Status Overview
|
||||
</Text>
|
||||
<Text size="2" color="gray">
|
||||
Everything is running smoothly in your {activeTab.toLowerCase()} section.
|
||||
</Text>
|
||||
<Button variant="surface" size="1" style={{ width: 'fit-content' }} mt="1">
|
||||
View Details
|
||||
</Button>
|
||||
</Flex>
|
||||
</Card>
|
||||
<Card size="2">
|
||||
<Flex direction="column" gap="2">
|
||||
<Text size="2" weight="bold">
|
||||
Recent Activity
|
||||
</Text>
|
||||
<Text size="2" color="gray">
|
||||
No recent changes detected in the last 24 hours.
|
||||
</Text>
|
||||
<Button variant="surface" size="1" style={{ width: 'fit-content' }} mt="1">
|
||||
Refresh
|
||||
</Button>
|
||||
</Flex>
|
||||
</Card>
|
||||
<Card size="2">
|
||||
<Flex direction="column" gap="2">
|
||||
<Text size="2" weight="bold">
|
||||
Quick Actions
|
||||
</Text>
|
||||
<Text size="2" color="gray">
|
||||
Common tasks related to {activeTab.toLowerCase()} are available here.
|
||||
</Text>
|
||||
<Button variant="solid" size="1" style={{ width: 'fit-content' }} mt="1">
|
||||
Get Started
|
||||
</Button>
|
||||
</Flex>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
{activeTab === 'Proxy Hosts' && (
|
||||
<Box mt="6">
|
||||
<Card variant="surface">
|
||||
<TablePlaceholder />
|
||||
</Card>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
88
apps/frontend/app/routes/layout.tsx
Normal file
88
apps/frontend/app/routes/layout.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { Flex, Box, Container, Dialog, Heading, IconButton, TextField } from '@radix-ui/themes';
|
||||
import SidebarContent from '../components/layout/SidebarContent';
|
||||
import { useLayout } from '../providers/LayoutProvider';
|
||||
import { Menu, Search, Bell } from 'lucide-react';
|
||||
import { Outlet } from 'react-router';
|
||||
|
||||
export default function LayoutContainer() {
|
||||
const { activeTab, isMobileMenuOpen, setIsMobileMenuOpen } = useLayout();
|
||||
return (
|
||||
<Flex style={{ minHeight: '100vh', backgroundColor: 'var(--gray-2)' }}>
|
||||
{/* Desktop Sidebar */}
|
||||
<Box
|
||||
display={{ initial: 'none', md: 'block' }}
|
||||
style={{
|
||||
width: '260px',
|
||||
backgroundColor: 'white',
|
||||
borderRight: '1px solid var(--gray-4)',
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
minHeight: '100vh',
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
<SidebarContent />
|
||||
</Box>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
{' '}
|
||||
{/* Top Header (Mobile & Desktop) */}
|
||||
<Flex
|
||||
align="center"
|
||||
justify="between"
|
||||
px="4"
|
||||
style={{
|
||||
height: '64px',
|
||||
backgroundColor: 'white',
|
||||
borderBottom: '1px solid var(--gray-4)',
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
<Flex align="center" gap="3">
|
||||
<Box display={{ md: 'none' }}>
|
||||
<Dialog.Root open={isMobileMenuOpen} onOpenChange={setIsMobileMenuOpen}>
|
||||
<Dialog.Trigger>
|
||||
<IconButton variant="ghost" color="gray">
|
||||
<Menu />
|
||||
</IconButton>
|
||||
</Dialog.Trigger>
|
||||
<Dialog.Content
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
margin: 0,
|
||||
width: '280px',
|
||||
borderRadius: 0,
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
<SidebarContent />
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
</Box>
|
||||
<Heading size="4">{activeTab}</Heading>
|
||||
</Flex>
|
||||
|
||||
<Flex align="center" gap="3">
|
||||
<TextField.Root placeholder="Search..." size="2">
|
||||
<TextField.Slot>
|
||||
<Search />
|
||||
</TextField.Slot>
|
||||
</TextField.Root>
|
||||
<IconButton variant="ghost" color="gray">
|
||||
<Bell />
|
||||
</IconButton>
|
||||
</Flex>
|
||||
</Flex>
|
||||
<Container size="4" p="5" style={{ paddingTop: 20 }}>
|
||||
<Outlet />
|
||||
</Container>
|
||||
</Box>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user