refactor: split frontend application features

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Daniel Noulet 2026-09-20 08:08:16 +02:00
parent 83d14d23a2
commit 5d7a57fd00
32 changed files with 2563 additions and 2433 deletions

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

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

View file

@ -0,0 +1,8 @@
export function Loading() {
return (
<div className="empty-state" role="status">
<span className="loading-spinner" />
<p>Loading…</p>
</div>
)
}

View 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'
}

View file

@ -0,0 +1,4 @@
export { useAuthSession } from './Account'
export { ErrorAlert } from './ErrorAlert'
export { errorMessage } from './errorMessage'
export { Loading } from './Loading'

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

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

View file

@ -0,0 +1,2 @@
export { FormActions } from './FormActions'
export { FormField } from './FormField'

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

View 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

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

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

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

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

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