feat: implement frontend login functionality with form handling and error management

This commit is contained in:
GW_MC
2025-12-19 18:33:34 +08:00
parent 5060c84f28
commit 227256e0e0
17 changed files with 765 additions and 27 deletions

View 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}
/>
</>
);
}

View File

@@ -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>
);
}

View 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>
);
}