feat: add admin layout with sidebar nav and sign out
This commit is contained in:
53
src/pages/admin/AdminLayout.module.css
Normal file
53
src/pages/admin/AdminLayout.module.css
Normal file
@@ -0,0 +1,53 @@
|
||||
.layout {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 220px;
|
||||
flex-shrink: 0;
|
||||
background: var(--color-charcoal);
|
||||
color: var(--color-cream);
|
||||
padding: var(--space-lg) var(--space-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-lg);
|
||||
}
|
||||
|
||||
.brand {
|
||||
font-family: var(--font-script);
|
||||
font-size: 1.5rem;
|
||||
color: var(--color-gold);
|
||||
}
|
||||
|
||||
.sidebar nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.navLink {
|
||||
color: var(--color-cream);
|
||||
text-decoration: none;
|
||||
padding: var(--space-sm);
|
||||
}
|
||||
|
||||
.navLinkActive {
|
||||
color: var(--color-gold);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.signOut {
|
||||
margin-top: auto;
|
||||
background: none;
|
||||
border: 1px solid var(--color-gold);
|
||||
color: var(--color-cream);
|
||||
padding: var(--space-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
padding: var(--space-xl) var(--space-lg);
|
||||
background: var(--color-cream-alt);
|
||||
}
|
||||
54
src/pages/admin/AdminLayout.test.tsx
Normal file
54
src/pages/admin/AdminLayout.test.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { MemoryRouter, Routes, Route } from 'react-router-dom'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
const signOutMock = vi.fn()
|
||||
const navigateMock = vi.fn()
|
||||
|
||||
vi.mock('../../lib/supabase', () => ({
|
||||
supabase: { auth: { signOut: (...args: unknown[]) => signOutMock(...args) } },
|
||||
}))
|
||||
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual('react-router-dom')
|
||||
return { ...actual, useNavigate: () => navigateMock }
|
||||
})
|
||||
|
||||
import AdminLayout from './AdminLayout'
|
||||
|
||||
function renderLayout() {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={['/admin/products']}>
|
||||
<Routes>
|
||||
<Route element={<AdminLayout />}>
|
||||
<Route path="/admin/products" element={<p>Products content</p>} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('AdminLayout', () => {
|
||||
beforeEach(() => {
|
||||
signOutMock.mockReset().mockResolvedValue({ error: null })
|
||||
navigateMock.mockReset()
|
||||
})
|
||||
|
||||
it('renders nav links for every admin section and the outlet content', () => {
|
||||
renderLayout()
|
||||
expect(screen.getByRole('link', { name: /products/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: /services/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: /schedule/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: /bookings/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: /inquiries/i })).toBeInTheDocument()
|
||||
expect(screen.getByText('Products content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('signs out and navigates to the login page', async () => {
|
||||
renderLayout()
|
||||
await userEvent.click(screen.getByRole('button', { name: /sign out/i }))
|
||||
expect(signOutMock).toHaveBeenCalled()
|
||||
expect(navigateMock).toHaveBeenCalledWith('/admin/login')
|
||||
})
|
||||
})
|
||||
47
src/pages/admin/AdminLayout.tsx
Normal file
47
src/pages/admin/AdminLayout.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { NavLink, Outlet, useNavigate } from 'react-router-dom'
|
||||
import { supabase } from '../../lib/supabase'
|
||||
import styles from './AdminLayout.module.css'
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ to: '/admin/products', label: 'Products' },
|
||||
{ to: '/admin/services', label: 'Services' },
|
||||
{ to: '/admin/schedule', label: 'Schedule' },
|
||||
{ to: '/admin/bookings', label: 'Bookings' },
|
||||
{ to: '/admin/inquiries', label: 'Inquiries' },
|
||||
]
|
||||
|
||||
function AdminLayout() {
|
||||
const navigate = useNavigate()
|
||||
|
||||
async function handleSignOut() {
|
||||
await supabase.auth.signOut()
|
||||
navigate('/admin/login')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.layout}>
|
||||
<aside className={styles.sidebar}>
|
||||
<p className={styles.brand}>Braids by Mira</p>
|
||||
<nav>
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={({ isActive }) => (isActive ? `${styles.navLink} ${styles.navLinkActive}` : styles.navLink)}
|
||||
>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
<button type="button" className={styles.signOut} onClick={handleSignOut}>
|
||||
Sign out
|
||||
</button>
|
||||
</aside>
|
||||
<main className={styles.content}>
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AdminLayout
|
||||
Reference in New Issue
Block a user