505 lines
13 KiB
Markdown
505 lines
13 KiB
Markdown
# React + Mantine + TanStack Start Integration Guide
|
|
|
|
## Overview
|
|
|
|
For WhatsHooked's admin interface, we'll use:
|
|
- **React 19**: Modern React with hooks and suspense
|
|
- **Mantine**: Component library for UI
|
|
- **TanStack Start**: Full-stack React framework with server-side rendering
|
|
- **Oranguru**: Enhanced Mantine components for grids and forms
|
|
|
|
## Project Structure
|
|
|
|
```
|
|
frontend/
|
|
├── app/
|
|
│ ├── routes/
|
|
│ │ ├── __root.tsx # Root layout
|
|
│ │ ├── index.tsx # Dashboard
|
|
│ │ ├── login.tsx # Login page
|
|
│ │ ├── users/
|
|
│ │ │ ├── index.tsx # User list
|
|
│ │ │ ├── new.tsx # Create user
|
|
│ │ │ └── $id/
|
|
│ │ │ ├── index.tsx # User details
|
|
│ │ │ └── edit.tsx # Edit user
|
|
│ │ ├── hooks/
|
|
│ │ │ ├── index.tsx # Hook list
|
|
│ │ │ └── ...
|
|
│ │ └── accounts/
|
|
│ │ ├── index.tsx # WhatsApp accounts
|
|
│ │ └── ...
|
|
│ ├── components/
|
|
│ │ ├── Layout.tsx
|
|
│ │ ├── Navbar.tsx
|
|
│ │ ├── UserGrid.tsx
|
|
│ │ └── ...
|
|
│ ├── lib/
|
|
│ │ ├── api.ts # API client
|
|
│ │ ├── auth.ts # Auth utilities
|
|
│ │ └── types.ts # TypeScript types
|
|
│ └── styles/
|
|
│ └── global.css
|
|
├── public/
|
|
│ └── assets/
|
|
├── package.json
|
|
├── tsconfig.json
|
|
└── vite.config.ts
|
|
```
|
|
|
|
## Installation
|
|
|
|
```bash
|
|
npm create @tanstack/start@latest
|
|
cd whatshooked-admin
|
|
npm install @mantine/core @mantine/hooks @mantine/notifications @mantine/form @mantine/datatable
|
|
npm install @warkypublic/oranguru
|
|
npm install @tanstack/react-query axios
|
|
npm install -D @types/react @types/react-dom
|
|
```
|
|
|
|
## Basic Setup
|
|
|
|
### app/routes/__root.tsx
|
|
|
|
```tsx
|
|
import { createRootRoute, Outlet } from '@tanstack/react-router';
|
|
import { MantineProvider } from '@mantine/core';
|
|
import { MantineBetterMenusProvider } from '@warkypublic/oranguru';
|
|
import { Notifications } from '@mantine/notifications';
|
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
|
|
const queryClient = new QueryClient();
|
|
|
|
export const Route = createRootRoute({
|
|
component: () => (
|
|
<QueryClientProvider client={queryClient}>
|
|
<MantineProvider>
|
|
<MantineBetterMenusProvider>
|
|
<Notifications />
|
|
<Outlet />
|
|
</MantineBetterMenusProvider>
|
|
</MantineProvider>
|
|
</QueryClientProvider>
|
|
),
|
|
});
|
|
```
|
|
|
|
### app/lib/api.ts
|
|
|
|
```typescript
|
|
import axios from 'axios';
|
|
|
|
const api = axios.create({
|
|
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:8825/api/v1',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
});
|
|
|
|
// Add auth token to requests
|
|
api.interceptors.request.use((config) => {
|
|
const token = localStorage.getItem('auth_token');
|
|
if (token) {
|
|
config.headers.Authorization = `Bearer ${token}`;
|
|
}
|
|
return config;
|
|
});
|
|
|
|
// Handle auth errors
|
|
api.interceptors.response.use(
|
|
(response) => response,
|
|
(error) => {
|
|
if (error.response?.status === 401) {
|
|
localStorage.removeItem('auth_token');
|
|
window.location.href = '/login';
|
|
}
|
|
return Promise.reject(error);
|
|
}
|
|
);
|
|
|
|
export default api;
|
|
|
|
// API methods
|
|
export const authApi = {
|
|
login: (username: string, password: string) =>
|
|
api.post('/auth/login', { username, password }),
|
|
|
|
logout: () =>
|
|
api.post('/auth/logout'),
|
|
|
|
getProfile: () =>
|
|
api.get('/auth/profile'),
|
|
};
|
|
|
|
export const usersApi = {
|
|
list: (params?: any) =>
|
|
api.get('/users', { params }),
|
|
|
|
get: (id: string) =>
|
|
api.get(`/users/${id}`),
|
|
|
|
create: (data: any) =>
|
|
api.post('/users', data),
|
|
|
|
update: (id: string, data: any) =>
|
|
api.put(`/users/${id}`, data),
|
|
|
|
delete: (id: string) =>
|
|
api.delete(`/users/${id}`),
|
|
};
|
|
|
|
export const hooksApi = {
|
|
list: () =>
|
|
api.get('/hooks'),
|
|
|
|
create: (data: any) =>
|
|
api.post('/hooks', data),
|
|
|
|
update: (id: string, data: any) =>
|
|
api.put(`/hooks/${id}`, data),
|
|
|
|
delete: (id: string) =>
|
|
api.delete(`/hooks/${id}`),
|
|
};
|
|
|
|
export const accountsApi = {
|
|
list: () =>
|
|
api.get('/accounts'),
|
|
|
|
pair: (accountId: string) =>
|
|
api.post(`/accounts/${accountId}/pair`),
|
|
|
|
disconnect: (accountId: string) =>
|
|
api.post(`/accounts/${accountId}/disconnect`),
|
|
|
|
getQRCode: (accountId: string) =>
|
|
api.get(`/accounts/${accountId}/qr`, { responseType: 'blob' }),
|
|
};
|
|
```
|
|
|
|
## Authentication
|
|
|
|
### app/routes/login.tsx
|
|
|
|
```tsx
|
|
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
|
import { useForm } from '@mantine/form';
|
|
import { TextInput, PasswordInput, Button, Paper, Title, Container } from '@mantine/core';
|
|
import { notifications } from '@mantine/notifications';
|
|
import { authApi } from '../lib/api';
|
|
|
|
export const Route = createFileRoute('/login')({
|
|
component: LoginPage,
|
|
});
|
|
|
|
function LoginPage() {
|
|
const navigate = useNavigate();
|
|
|
|
const form = useForm({
|
|
initialValues: {
|
|
username: '',
|
|
password: '',
|
|
},
|
|
validate: {
|
|
username: (value) => (value.length < 3 ? 'Username too short' : null),
|
|
password: (value) => (value.length < 6 ? 'Password too short' : null),
|
|
},
|
|
});
|
|
|
|
const handleSubmit = async (values: typeof form.values) => {
|
|
try {
|
|
const response = await authApi.login(values.username, values.password);
|
|
localStorage.setItem('auth_token', response.data.token);
|
|
notifications.show({
|
|
title: 'Success',
|
|
message: 'Logged in successfully',
|
|
color: 'green',
|
|
});
|
|
navigate({ to: '/' });
|
|
} catch (error) {
|
|
notifications.show({
|
|
title: 'Error',
|
|
message: 'Invalid credentials',
|
|
color: 'red',
|
|
});
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Container size={420} my={40}>
|
|
<Title ta="center">WhatsHooked Admin</Title>
|
|
<Paper withBorder shadow="md" p={30} mt={30} radius="md">
|
|
<form onSubmit={form.onSubmit(handleSubmit)}>
|
|
<TextInput
|
|
label="Username"
|
|
placeholder="admin"
|
|
required
|
|
{...form.getInputProps('username')}
|
|
/>
|
|
<PasswordInput
|
|
label="Password"
|
|
placeholder="Your password"
|
|
required
|
|
mt="md"
|
|
{...form.getInputProps('password')}
|
|
/>
|
|
<Button fullWidth mt="xl" type="submit">
|
|
Sign in
|
|
</Button>
|
|
</form>
|
|
</Paper>
|
|
</Container>
|
|
);
|
|
}
|
|
```
|
|
|
|
## Data Grid with Oranguru
|
|
|
|
### app/routes/users/index.tsx
|
|
|
|
```tsx
|
|
import { createFileRoute } from '@tanstack/react-router';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import { DataTable } from '@mantine/datatable';
|
|
import { useMantineBetterMenus } from '@warkypublic/oranguru';
|
|
import { Button, Group, Text } from '@mantine/core';
|
|
import { notifications } from '@mantine/notifications';
|
|
import { usersApi } from '../../lib/api';
|
|
|
|
export const Route = createFileRoute('/users/')({
|
|
component: UsersPage,
|
|
});
|
|
|
|
function UsersPage() {
|
|
const { show } = useMantineBetterMenus();
|
|
|
|
const { data: users, isLoading, refetch } = useQuery({
|
|
queryKey: ['users'],
|
|
queryFn: async () => {
|
|
const response = await usersApi.list();
|
|
return response.data;
|
|
},
|
|
});
|
|
|
|
const handleContextMenu = (e: React.MouseEvent, user: any) => {
|
|
e.preventDefault();
|
|
show('user-menu', {
|
|
x: e.clientX,
|
|
y: e.clientY,
|
|
items: [
|
|
{
|
|
label: 'Edit',
|
|
onClick: () => navigate({ to: `/users/${user.id}/edit` }),
|
|
},
|
|
{
|
|
label: 'View Details',
|
|
onClick: () => navigate({ to: `/users/${user.id}` }),
|
|
},
|
|
{
|
|
isDivider: true,
|
|
},
|
|
{
|
|
label: 'Delete',
|
|
onClickAsync: async () => {
|
|
await usersApi.delete(user.id);
|
|
notifications.show({
|
|
message: 'User deleted successfully',
|
|
color: 'green',
|
|
});
|
|
refetch();
|
|
},
|
|
},
|
|
],
|
|
});
|
|
};
|
|
|
|
const columns = [
|
|
{ accessor: 'name', title: 'Name' },
|
|
{ accessor: 'email', title: 'Email' },
|
|
{ accessor: 'role', title: 'Role' },
|
|
{
|
|
accessor: 'actions',
|
|
title: '',
|
|
render: (user: any) => (
|
|
<Group gap="xs">
|
|
<Button size="xs" onClick={() => navigate({ to: `/users/${user.id}/edit` })}>
|
|
Edit
|
|
</Button>
|
|
</Group>
|
|
),
|
|
},
|
|
];
|
|
|
|
return (
|
|
<div>
|
|
<Group justify="space-between" mb="md">
|
|
<Text size="xl" fw={700}>Users</Text>
|
|
<Button onClick={() => navigate({ to: '/users/new' })}>
|
|
Create User
|
|
</Button>
|
|
</Group>
|
|
|
|
<DataTable
|
|
columns={columns}
|
|
records={users || []}
|
|
fetching={isLoading}
|
|
onRowContextMenu={({ event, record }) => handleContextMenu(event, record)}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
## Forms
|
|
|
|
### app/routes/users/new.tsx
|
|
|
|
```tsx
|
|
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
|
import { useForm } from '@mantine/form';
|
|
import { TextInput, Select, Button, Paper } from '@mantine/core';
|
|
import { notifications } from '@mantine/notifications';
|
|
import { usersApi } from '../../lib/api';
|
|
|
|
export const Route = createFileRoute('/users/new')({
|
|
component: NewUserPage,
|
|
});
|
|
|
|
function NewUserPage() {
|
|
const navigate = useNavigate();
|
|
|
|
const form = useForm({
|
|
initialValues: {
|
|
name: '',
|
|
email: '',
|
|
password: '',
|
|
role: 'user',
|
|
},
|
|
validate: {
|
|
name: (value) => (value.length < 2 ? 'Name too short' : null),
|
|
email: (value) => (/^\S+@\S+$/.test(value) ? null : 'Invalid email'),
|
|
password: (value) => (value.length < 6 ? 'Password too short' : null),
|
|
},
|
|
});
|
|
|
|
const handleSubmit = async (values: typeof form.values) => {
|
|
try {
|
|
await usersApi.create(values);
|
|
notifications.show({
|
|
message: 'User created successfully',
|
|
color: 'green',
|
|
});
|
|
navigate({ to: '/users' });
|
|
} catch (error) {
|
|
notifications.show({
|
|
message: 'Failed to create user',
|
|
color: 'red',
|
|
});
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Paper p="md">
|
|
<form onSubmit={form.onSubmit(handleSubmit)}>
|
|
<TextInput
|
|
label="Name"
|
|
placeholder="John Doe"
|
|
required
|
|
{...form.getInputProps('name')}
|
|
/>
|
|
<TextInput
|
|
label="Email"
|
|
placeholder="john@example.com"
|
|
required
|
|
mt="md"
|
|
{...form.getInputProps('email')}
|
|
/>
|
|
<PasswordInput
|
|
label="Password"
|
|
placeholder="Password"
|
|
required
|
|
mt="md"
|
|
{...form.getInputProps('password')}
|
|
/>
|
|
<Select
|
|
label="Role"
|
|
data={['admin', 'user', 'viewer']}
|
|
required
|
|
mt="md"
|
|
{...form.getInputProps('role')}
|
|
/>
|
|
<Group justify="flex-end" mt="xl">
|
|
<Button variant="subtle" onClick={() => navigate({ to: '/users' })}>
|
|
Cancel
|
|
</Button>
|
|
<Button type="submit">Create</Button>
|
|
</Group>
|
|
</form>
|
|
</Paper>
|
|
);
|
|
}
|
|
```
|
|
|
|
## Layout with Navigation
|
|
|
|
### app/components/Layout.tsx
|
|
|
|
```tsx
|
|
import { AppShell, NavLink, Group, Title } from '@mantine/core';
|
|
import { IconUsers, IconWebhook, IconBrandWhatsapp, IconKey } from '@tabler/icons-react';
|
|
import { Link, useLocation } from '@tanstack/react-router';
|
|
|
|
export function Layout({ children }: { children: React.ReactNode }) {
|
|
const location = useLocation();
|
|
|
|
const navItems = [
|
|
{ icon: IconUsers, label: 'Users', to: '/users' },
|
|
{ icon: IconWebhook, label: 'Hooks', to: '/hooks' },
|
|
{ icon: IconBrandWhatsapp, label: 'WhatsApp Accounts', to: '/accounts' },
|
|
{ icon: IconKey, label: 'API Keys', to: '/api-keys' },
|
|
];
|
|
|
|
return (
|
|
<AppShell
|
|
navbar={{ width: 250, breakpoint: 'sm' }}
|
|
padding="md"
|
|
>
|
|
<AppShell.Navbar p="md">
|
|
<Title order={3} mb="md">WhatsHooked</Title>
|
|
{navItems.map((item) => (
|
|
<NavLink
|
|
key={item.to}
|
|
component={Link}
|
|
to={item.to}
|
|
label={item.label}
|
|
leftSection={<item.icon size={20} />}
|
|
active={location.pathname.startsWith(item.to)}
|
|
/>
|
|
))}
|
|
</AppShell.Navbar>
|
|
|
|
<AppShell.Main>{children}</AppShell.Main>
|
|
</AppShell>
|
|
);
|
|
}
|
|
```
|
|
|
|
## Best Practices
|
|
|
|
1. **Code Splitting**: Use lazy loading for routes
|
|
2. **Error Boundaries**: Wrap components in error boundaries
|
|
3. **Loading States**: Show loading indicators with Suspense
|
|
4. **Optimistic Updates**: Update UI before API response
|
|
5. **Form Validation**: Use Mantine form with validation
|
|
6. **Type Safety**: Use TypeScript for all API calls
|
|
7. **Query Invalidation**: Refetch data after mutations
|
|
8. **Auth Protection**: Protect routes with auth guards
|
|
|
|
## References
|
|
|
|
- TanStack Start: https://tanstack.com/start
|
|
- Mantine: https://mantine.dev
|
|
- TanStack Query: https://tanstack.com/query
|
|
- Oranguru: See ORANGURU.md
|