refactor: split frontend application features
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
83d14d23a2
commit
5d7a57fd00
32 changed files with 2563 additions and 2433 deletions
47
frontend/src/components/actions/Account.tsx
Normal file
47
frontend/src/components/actions/Account.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { authStorage, invalidateSession, SESSION_EXPIRED_EVENT } from '../../api/client'
|
||||
|
||||
export function useAuthSession() {
|
||||
const [authenticated, setAuthenticated] = useState(() => Boolean(authStorage.token()))
|
||||
const [sessionExpired, setSessionExpired] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
function handleSessionExpired() {
|
||||
setAuthenticated(false)
|
||||
setSessionExpired(true)
|
||||
navigate('/login', { replace: true })
|
||||
}
|
||||
|
||||
window.addEventListener(SESSION_EXPIRED_EVENT, handleSessionExpired)
|
||||
return () => window.removeEventListener(SESSION_EXPIRED_EVENT, handleSessionExpired)
|
||||
}, [navigate])
|
||||
|
||||
useEffect(() => {
|
||||
if (!authenticated) return
|
||||
const expiresAt = authStorage.expiresAt()
|
||||
if (expiresAt === null) return
|
||||
const remaining = expiresAt - Date.now()
|
||||
if (remaining <= 0) {
|
||||
invalidateSession()
|
||||
return
|
||||
}
|
||||
const timeout = window.setTimeout(invalidateSession, remaining)
|
||||
return () => window.clearTimeout(timeout)
|
||||
}, [authenticated])
|
||||
|
||||
function completeLogin() {
|
||||
setAuthenticated(true)
|
||||
setSessionExpired(false)
|
||||
navigate('/jobs', { replace: true })
|
||||
}
|
||||
|
||||
function logout() {
|
||||
authStorage.clear()
|
||||
setAuthenticated(false)
|
||||
setSessionExpired(false)
|
||||
}
|
||||
|
||||
return { authenticated, sessionExpired, completeLogin, logout }
|
||||
}
|
||||
19
frontend/src/components/actions/ErrorAlert.tsx
Normal file
19
frontend/src/components/actions/ErrorAlert.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
export function ErrorAlert({
|
||||
message,
|
||||
retry,
|
||||
}: {
|
||||
message: string
|
||||
retry?: () => void | Promise<void>
|
||||
}) {
|
||||
return (
|
||||
<div className="error-alert" role="alert">
|
||||
<strong>Request failed</strong>
|
||||
<span>{message}</span>
|
||||
{retry && (
|
||||
<button type="button" onClick={() => void retry()}>
|
||||
Try again
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
8
frontend/src/components/actions/Loading.tsx
Normal file
8
frontend/src/components/actions/Loading.tsx
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
export function Loading() {
|
||||
return (
|
||||
<div className="empty-state" role="status">
|
||||
<span className="loading-spinner" />
|
||||
<p>Loading…</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
7
frontend/src/components/actions/errorMessage.ts
Normal file
7
frontend/src/components/actions/errorMessage.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { ApiError } from '../../api/client'
|
||||
|
||||
export function errorMessage(error: unknown) {
|
||||
return error instanceof ApiError || error instanceof Error
|
||||
? error.message
|
||||
: 'Something went wrong'
|
||||
}
|
||||
4
frontend/src/components/actions/index.ts
Normal file
4
frontend/src/components/actions/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export { useAuthSession } from './Account'
|
||||
export { ErrorAlert } from './ErrorAlert'
|
||||
export { errorMessage } from './errorMessage'
|
||||
export { Loading } from './Loading'
|
||||
24
frontend/src/components/form/FormActions.tsx
Normal file
24
frontend/src/components/form/FormActions.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { Link } from 'react-router-dom'
|
||||
import { ArrowRight } from 'lucide-react'
|
||||
|
||||
export function FormActions({
|
||||
saving,
|
||||
cancelTo,
|
||||
label,
|
||||
}: {
|
||||
saving: boolean
|
||||
cancelTo: string
|
||||
label: string
|
||||
}) {
|
||||
return (
|
||||
<div className="form-actions">
|
||||
<Link className="button ghost" to={cancelTo}>
|
||||
Cancel
|
||||
</Link>
|
||||
<button className="button primary" type="submit" disabled={saving}>
|
||||
{saving ? 'Saving…' : label}
|
||||
<ArrowRight size={17} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
23
frontend/src/components/form/FormField.tsx
Normal file
23
frontend/src/components/form/FormField.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import type { ReactNode } from 'react'
|
||||
|
||||
export function FormField({
|
||||
label,
|
||||
htmlFor,
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
label: string
|
||||
htmlFor: string
|
||||
hint?: string
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="field">
|
||||
<label className="field-label" htmlFor={htmlFor}>
|
||||
{label}
|
||||
</label>
|
||||
{children}
|
||||
{hint && <small>{hint}</small>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
2
frontend/src/components/form/index.ts
Normal file
2
frontend/src/components/form/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { FormActions } from './FormActions'
|
||||
export { FormField } from './FormField'
|
||||
32
frontend/src/components/layout/GoGuessHeader.tsx
Normal file
32
frontend/src/components/layout/GoGuessHeader.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { Menu } from 'lucide-react'
|
||||
import Logo from './Logo'
|
||||
import { SideBar } from './SideBar'
|
||||
|
||||
export function GoGuessHeader({
|
||||
open,
|
||||
setOpen,
|
||||
onLogout,
|
||||
}: {
|
||||
open: boolean
|
||||
setOpen: (value: boolean) => void
|
||||
onLogout: () => void
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<header className="mobile-header">
|
||||
<Logo />
|
||||
<button className="icon-button" onClick={() => setOpen(true)} aria-label="Open navigation">
|
||||
<Menu />
|
||||
</button>
|
||||
</header>
|
||||
{open && (
|
||||
<button
|
||||
className="nav-backdrop"
|
||||
onClick={() => setOpen(false)}
|
||||
aria-label="Close navigation"
|
||||
/>
|
||||
)}
|
||||
<SideBar open={open} setOpen={setOpen} onLogout={onLogout} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
17
frontend/src/components/layout/Logo.tsx
Normal file
17
frontend/src/components/layout/Logo.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { Link } from 'react-router-dom'
|
||||
|
||||
function Logo() {
|
||||
return (
|
||||
<Link className="logo" to="/jobs" aria-label="Go Guess home">
|
||||
<span className="logo-mark" aria-hidden="true">
|
||||
G
|
||||
</span>
|
||||
<span>
|
||||
<strong>GO GUESS</strong>
|
||||
<small>Talent workspace</small>
|
||||
</span>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export default Logo
|
||||
25
frontend/src/components/layout/NavItem.tsx
Normal file
25
frontend/src/components/layout/NavItem.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { NavLink } from 'react-router-dom'
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
export function NavItem({
|
||||
to,
|
||||
label,
|
||||
icon,
|
||||
close,
|
||||
}: {
|
||||
to: string
|
||||
label: string
|
||||
icon: ReactNode
|
||||
close: () => void
|
||||
}) {
|
||||
return (
|
||||
<NavLink
|
||||
to={to}
|
||||
onClick={close}
|
||||
className={({ isActive }) => (isActive ? 'nav-link active' : 'nav-link')}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</NavLink>
|
||||
)
|
||||
}
|
||||
57
frontend/src/components/layout/SideBar.tsx
Normal file
57
frontend/src/components/layout/SideBar.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import {
|
||||
BriefcaseBusiness,
|
||||
CalendarDays,
|
||||
CircleHelp,
|
||||
LogOut,
|
||||
UserRound,
|
||||
UsersRound,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import Logo from './Logo'
|
||||
import { NavItem } from './NavItem'
|
||||
|
||||
export function SideBar({
|
||||
open,
|
||||
setOpen,
|
||||
onLogout,
|
||||
}: {
|
||||
open: boolean
|
||||
setOpen: (value: boolean) => void
|
||||
onLogout: () => void
|
||||
}) {
|
||||
const close = () => setOpen(false)
|
||||
return (
|
||||
<aside className={`sidebar ${open ? 'open' : ''}`}>
|
||||
<div className="sidebar-top">
|
||||
<Logo />
|
||||
<button className="icon-button mobile-only" onClick={close} aria-label="Close navigation">
|
||||
<X />
|
||||
</button>
|
||||
</div>
|
||||
<nav aria-label="Primary navigation">
|
||||
<p className="nav-label">Workspace</p>
|
||||
<NavItem to="/jobs" label="Jobs" icon={<BriefcaseBusiness size={20} />} close={close} />
|
||||
<NavItem
|
||||
to="/participants"
|
||||
label="Participants"
|
||||
icon={<UsersRound size={20} />}
|
||||
close={close}
|
||||
/>
|
||||
<NavItem
|
||||
to="/questions"
|
||||
label="Question library"
|
||||
icon={<CircleHelp size={20} />}
|
||||
close={close}
|
||||
/>
|
||||
<NavItem to="/calendar" label="Calendar" icon={<CalendarDays size={20} />} close={close} />
|
||||
<NavItem to="/users" label="Users" icon={<UserRound size={20} />} close={close} />
|
||||
</nav>
|
||||
<div className="sidebar-bottom">
|
||||
<button className="sidebar-signout" onClick={onLogout}>
|
||||
<LogOut size={18} />
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
33
frontend/src/components/ui/Display.tsx
Normal file
33
frontend/src/components/ui/Display.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { LayoutDashboard } from 'lucide-react'
|
||||
|
||||
export function StatusBadge({ value }: { value: string }) {
|
||||
return <span className={`status status-${value.toLowerCase()}`}>{value}</span>
|
||||
}
|
||||
|
||||
export function Metric({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<article className="metric">
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
export function Fact({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="fact">
|
||||
<small>{label}</small>
|
||||
<strong>{value}</strong>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function EmptyState({ title, description }: { title: string; description: string }) {
|
||||
return (
|
||||
<div className="empty-state">
|
||||
<LayoutDashboard />
|
||||
<h3>{title}</h3>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
14
frontend/src/components/ui/NotFound.tsx
Normal file
14
frontend/src/components/ui/NotFound.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { Link } from 'react-router-dom'
|
||||
|
||||
export function NotFound() {
|
||||
return (
|
||||
<section className="empty-page">
|
||||
<span className="eyebrow">404</span>
|
||||
<h1>Page not found</h1>
|
||||
<p>The page you requested is not available.</p>
|
||||
<Link className="button primary" to="/jobs">
|
||||
Back to jobs
|
||||
</Link>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
24
frontend/src/components/ui/PageHeader.tsx
Normal file
24
frontend/src/components/ui/PageHeader.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import type { ReactNode } from 'react'
|
||||
|
||||
export function PageHeader({
|
||||
eyebrow,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
}: {
|
||||
eyebrow: string
|
||||
title: string
|
||||
description?: string
|
||||
action?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<header className="page-header">
|
||||
<div>
|
||||
<span className="eyebrow">{eyebrow}</span>
|
||||
<h1>{title}</h1>
|
||||
{description && <p>{description}</p>}
|
||||
</div>
|
||||
{action}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue