2 Commits

Author SHA1 Message Date
2c45dd0065 Move account actions into avatar menu 2026-01-25 16:48:38 +13:00
92959d80ab Improve mobile header layout 2026-01-25 16:36:29 +13:00
6 changed files with 170 additions and 41 deletions

View File

@@ -300,7 +300,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
requests_data_source: 'Pick where Magent should read requests from.', requests_data_source: 'Pick where Magent should read requests from.',
log_level: 'How much detail is written to the activity log.', log_level: 'How much detail is written to the activity log.',
log_file: 'Where the activity log is stored.', log_file: 'Where the activity log is stored.',
site_build_number: 'Build number shown in the footer (auto-set from releases).', site_build_number: 'Build number shown in the account menu (auto-set from releases).',
site_banner_enabled: 'Enable a sitewide banner for announcements.', site_banner_enabled: 'Enable a sitewide banner for announcements.',
site_banner_message: 'Short banner message for maintenance or updates.', site_banner_message: 'Short banner message for maintenance or updates.',
site_banner_tone: 'Visual tone for the banner.', site_banner_tone: 'Visual tone for the banner.',

View File

@@ -175,30 +175,35 @@ body {
margin-right: auto; margin-right: auto;
} }
.signed-in {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--ink-muted);
padding: 6px 10px;
border-radius: 999px;
border: 1px dashed var(--border);
background: transparent;
cursor: pointer;
}
.signed-in-menu { .signed-in-menu {
position: relative; position: relative;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
} }
.avatar-button {
width: 44px;
height: 44px;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.12);
background: linear-gradient(130deg, rgba(28, 107, 255, 0.35), rgba(17, 214, 198, 0.25));
color: var(--ink);
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
display: inline-flex;
align-items: center;
justify-content: center;
box-shadow: 0 10px 20px rgba(28, 107, 255, 0.25);
cursor: pointer;
}
.signed-in-dropdown { .signed-in-dropdown {
position: absolute; position: absolute;
top: calc(100% + 8px); top: calc(100% + 8px);
right: 0; right: 0;
min-width: 180px; width: min(260px, 90vw);
background: rgba(14, 20, 32, 0.95); background: rgba(14, 20, 32, 0.96);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 12px; border-radius: 12px;
padding: 8px; padding: 8px;
@@ -206,17 +211,50 @@ body {
z-index: 20; z-index: 20;
} }
.signed-in-dropdown a { .signed-in-header {
font-size: 11px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--ink-muted);
padding: 8px 10px 6px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.signed-in-actions {
display: grid;
gap: 6px;
padding: 8px 4px 4px;
}
.signed-in-actions a,
.signed-in-signout {
display: block; display: block;
padding: 8px 12px; padding: 8px 12px;
border-radius: 10px; border-radius: 10px;
color: var(--ink); color: var(--ink);
text-decoration: none; text-decoration: none;
text-align: center; text-align: left;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.08);
} }
.signed-in-dropdown a:hover { .signed-in-signout {
background: rgba(255, 255, 255, 0.08); cursor: pointer;
font: inherit;
}
.signed-in-actions a:hover,
.signed-in-signout:hover {
background: rgba(255, 255, 255, 0.12);
}
.signed-in-build {
margin-top: 6px;
padding: 6px 10px 8px;
font-size: 11px;
color: var(--ink-muted);
text-align: left;
letter-spacing: 0.04em;
} }
.theme-toggle { .theme-toggle {
@@ -1399,19 +1437,83 @@ button span {
} }
@media (max-width: 720px) { @media (max-width: 720px) {
.page {
padding: 28px 18px 60px;
gap: 24px;
}
.header { .header {
grid-template-columns: 1fr; grid-template-columns: 1fr;
grid-template-rows: auto auto auto; grid-template-rows: auto auto auto;
align-items: flex-start; align-items: flex-start;
} }
.header-left {
width: 100%;
}
.brand-link {
width: 100%;
gap: 12px;
}
.brand-logo--header {
width: 64px;
height: 64px;
}
.brand {
font-size: 26px;
}
.tagline {
font-size: 13px;
}
.header-right { .header-right {
grid-column: 1 / -1; grid-column: 1 / -1;
justify-content: flex-start; justify-content: flex-start;
width: 100%;
flex-wrap: wrap;
gap: 10px;
} }
.header-nav { .header-nav {
justify-content: flex-start; justify-content: flex-start;
width: 100%;
}
.signed-in-menu {
margin-left: auto;
}
.avatar-button {
width: 40px;
height: 40px;
}
.signed-in-dropdown {
right: 0;
left: auto;
width: min(260px, 92vw);
}
.header-actions {
width: 100%;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.header-actions a,
.header-actions .header-link {
font-size: 12px;
padding: 8px 10px;
}
.header-actions .header-cta--left {
grid-column: 1 / -1;
margin-right: 0;
} }
.summary { .summary {
@@ -1451,6 +1553,12 @@ button span {
} }
} }
@media (max-width: 480px) {
.header-actions {
grid-template-columns: 1fr;
}
}
/* Loading spinner */ /* Loading spinner */
.loading-center { .loading-center {
display: flex; display: flex;

View File

@@ -29,8 +29,8 @@ export default function RootLayout({ children }: { children: ReactNode }) {
</a> </a>
</div> </div>
<div className="header-right"> <div className="header-right">
<HeaderIdentity />
<ThemeToggle /> <ThemeToggle />
<HeaderIdentity />
</div> </div>
<div className="header-nav"> <div className="header-nav">
<HeaderActions /> <HeaderActions />

View File

@@ -32,14 +32,6 @@ export default function HeaderActions() {
void load() void load()
}, []) }, [])
const signOut = () => {
clearToken()
setSignedIn(false)
if (typeof window !== 'undefined') {
window.location.href = '/login'
}
}
if (!signedIn) { if (!signedIn) {
return null return null
} }
@@ -49,12 +41,7 @@ export default function HeaderActions() {
<a className="header-cta header-cta--left" href="/feedback">Send feedback</a> <a className="header-cta header-cta--left" href="/feedback">Send feedback</a>
<a href="/">Requests</a> <a href="/">Requests</a>
<a href="/how-it-works">How it works</a> <a href="/how-it-works">How it works</a>
<a href="/changelog">Changelog</a>
<a href="/profile">My profile</a>
{role === 'admin' && <a href="/admin">Settings</a>} {role === 'admin' && <a href="/admin">Settings</a>}
<button type="button" className="header-link" onClick={signOut}>
Sign out
</button>
</div> </div>
) )
} }

View File

@@ -4,13 +4,15 @@ import { useEffect, useState } from 'react'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth' import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
export default function HeaderIdentity() { export default function HeaderIdentity() {
const [identity, setIdentity] = useState<string | null>(null) const [identity, setIdentity] = useState<{ username: string; role?: string } | null>(null)
const [buildNumber, setBuildNumber] = useState<string | null>(null)
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
useEffect(() => { useEffect(() => {
const token = getToken() const token = getToken()
if (!token) { if (!token) {
setIdentity(null) setIdentity(null)
setBuildNumber(null)
return return
} }
const load = async () => { const load = async () => {
@@ -24,7 +26,14 @@ export default function HeaderIdentity() {
} }
const data = await response.json() const data = await response.json()
if (data?.username) { if (data?.username) {
setIdentity(`${data.username}${data.role ? ` (${data.role})` : ''}`) setIdentity({ username: data.username, role: data.role })
}
const siteResponse = await fetch(`${baseUrl}/site/public`)
if (siteResponse.ok) {
const siteInfo = await siteResponse.json()
if (siteInfo?.buildNumber) {
setBuildNumber(siteInfo.buildNumber)
}
} }
} catch (err) { } catch (err) {
console.error(err) console.error(err)
@@ -38,14 +47,42 @@ export default function HeaderIdentity() {
return null return null
} }
const label = `${identity.username}${identity.role ? ` (${identity.role})` : ''}`
const initial = identity.username.slice(0, 1).toUpperCase()
const signOut = () => {
clearToken()
if (typeof window !== 'undefined') {
window.location.href = '/login'
}
}
return ( return (
<div className="signed-in-menu"> <div className="signed-in-menu">
<button type="button" className="signed-in" onClick={() => setOpen((prev) => !prev)}> <button
Signed in as {identity} type="button"
className="avatar-button"
onClick={() => setOpen((prev) => !prev)}
aria-haspopup="true"
aria-expanded={open}
title={label}
>
{initial}
</button> </button>
{open && ( {open && (
<div className="signed-in-dropdown"> <div className="signed-in-dropdown">
<a href="/profile">My profile</a> <div className="signed-in-header">Signed in as {label}</div>
<div className="signed-in-actions">
<a href="/profile" onClick={() => setOpen(false)}>
My profile
</a>
<a href="/changelog" onClick={() => setOpen(false)}>
Changelog
</a>
<button type="button" className="signed-in-signout" onClick={signOut}>
Sign out
</button>
</div>
{buildNumber ? <div className="signed-in-build">Build {buildNumber}</div> : null}
</div> </div>
)} )}
</div> </div>

View File

@@ -57,9 +57,6 @@ export default function SiteStatus() {
{banner?.enabled && banner.message ? ( {banner?.enabled && banner.message ? (
<div className={`site-banner site-banner--${tone}`}>{banner.message}</div> <div className={`site-banner site-banner--${tone}`}>{banner.message}</div>
) : null} ) : null}
{info?.buildNumber ? (
<div className="site-version">Build {info.buildNumber}</div>
) : null}
</> </>
) )
} }