Add avatars, profiles, and auth flows

This commit is contained in:
Micha
2026-01-12 23:40:11 +01:00
parent bbbf8eb6c1
commit 3bb2946656
30 changed files with 691 additions and 48 deletions

View File

@@ -10,10 +10,19 @@ import Register from './pages/Register'
import Acp from './pages/Acp'
import BoardIndex from './pages/BoardIndex'
import Ucp from './pages/Ucp'
import Profile from './pages/Profile'
import { useTranslation } from 'react-i18next'
import { fetchSettings, fetchVersion, getForum, getThread } from './api/client'
function PortalHeader({ userMenu, isAuthenticated, forumName, logoUrl, showHeaderName }) {
function PortalHeader({
userMenu,
isAuthenticated,
forumName,
logoUrl,
showHeaderName,
canAccessAcp,
canAccessMcp,
}) {
const { t } = useTranslation()
const location = useLocation()
const [crumbs, setCrumbs] = useState([])
@@ -107,7 +116,7 @@ function PortalHeader({ userMenu, isAuthenticated, forumName, logoUrl, showHeade
}, [location.pathname, t])
return (
<Container className="pt-2 pb-2 bb-portal-shell">
<Container fluid className="pt-2 pb-2 bb-portal-shell">
<div className="bb-portal-banner">
<div className="bb-portal-brand">
{logoUrl && (
@@ -135,12 +144,18 @@ function PortalHeader({ userMenu, isAuthenticated, forumName, logoUrl, showHeade
<span>
<i className="bi bi-question-circle-fill" aria-hidden="true" /> {t('portal.link_faq')}
</span>
<Link to="/acp" className="bb-portal-link">
<i className="bi bi-gear-fill" aria-hidden="true" /> {t('portal.link_acp')}
</Link>
<span>
<i className="bi bi-hammer" aria-hidden="true" /> {t('portal.link_mcp')}
</span>
{isAuthenticated && canAccessAcp && (
<>
<Link to="/acp" className="bb-portal-link">
<i className="bi bi-gear-fill" aria-hidden="true" /> {t('portal.link_acp')}
</Link>
</>
)}
{isAuthenticated && canAccessMcp && (
<span>
<i className="bi bi-hammer" aria-hidden="true" /> {t('portal.link_mcp')}
</span>
)}
</div>
</div>
<div
@@ -197,7 +212,7 @@ function PortalHeader({ userMenu, isAuthenticated, forumName, logoUrl, showHeade
function AppShell() {
const { t } = useTranslation()
const { token, email, logout, isAdmin } = useAuth()
const { token, email, userId, logout, isAdmin, isModerator } = useAuth()
const [versionInfo, setVersionInfo] = useState(null)
const [theme, setTheme] = useState('auto')
const [resolvedTheme, setResolvedTheme] = useState('light')
@@ -403,7 +418,7 @@ function AppShell() {
<NavDropdown.Item as={Link} to="/ucp">
<i className="bi bi-sliders" aria-hidden="true" /> {t('portal.user_control_panel')}
</NavDropdown.Item>
<NavDropdown.Item as={Link} to="/ucp">
<NavDropdown.Item as={Link} to={`/profile/${userId ?? ''}`}>
<i className="bi bi-person" aria-hidden="true" /> {t('portal.user_profile')}
</NavDropdown.Item>
<NavDropdown.Divider />
@@ -413,6 +428,8 @@ function AppShell() {
</NavDropdown>
) : null
}
canAccessAcp={isAdmin}
canAccessMcp={isModerator}
/>
<Routes>
<Route path="/" element={<Home />} />
@@ -421,6 +438,7 @@ function AppShell() {
<Route path="/thread/:id" element={<ThreadView />} />
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/profile/:id" element={<Profile />} />
<Route path="/acp/*" element={<Acp isAdmin={isAdmin} />} />
<Route
path="/ucp"

View File

@@ -48,10 +48,10 @@ export async function getCollection(path) {
return data?.['hydra:member'] || []
}
export async function login(email, password) {
export async function login(login, password) {
return apiFetch('/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
body: JSON.stringify({ login, password }),
})
}
@@ -70,6 +70,23 @@ export async function listAllForums() {
return getCollection('/forums?pagination=false')
}
export async function getCurrentUser() {
return apiFetch('/user/me')
}
export async function uploadAvatar(file) {
const body = new FormData()
body.append('file', file)
return apiFetch('/user/avatar', {
method: 'POST',
body,
})
}
export async function getUserProfile(id) {
return apiFetch(`/user/profile/${id}`)
}
export async function fetchVersion() {
return apiFetch('/version')
}

View File

@@ -27,10 +27,11 @@ export function AuthProvider({ children }) {
userId: effectiveUserId,
roles: effectiveRoles,
isAdmin: effectiveRoles.includes('ROLE_ADMIN'),
async login(emailInput, password) {
const data = await apiLogin(emailInput, password)
isModerator: effectiveRoles.includes('ROLE_MODERATOR') || effectiveRoles.includes('ROLE_ADMIN'),
async login(loginInput, password) {
const data = await apiLogin(loginInput, password)
localStorage.setItem('speedbb_token', data.token)
localStorage.setItem('speedbb_email', data.email || emailInput)
localStorage.setItem('speedbb_email', data.email || loginInput)
if (data.user_id) {
localStorage.setItem('speedbb_user_id', String(data.user_id))
setUserId(String(data.user_id))
@@ -43,7 +44,7 @@ export function AuthProvider({ children }) {
setRoles([])
}
setToken(data.token)
setEmail(data.email || emailInput)
setEmail(data.email || loginInput)
},
logout() {
localStorage.removeItem('speedbb_token')
@@ -77,6 +78,7 @@ export function AuthProvider({ children }) {
userId: effectiveUserId,
roles: effectiveRoles,
isAdmin: effectiveRoles.includes('ROLE_ADMIN'),
isModerator: effectiveRoles.includes('ROLE_MODERATOR') || effectiveRoles.includes('ROLE_ADMIN'),
hasToken: Boolean(token),
})
}, [email, effectiveUserId, effectiveRoles, token])

View File

@@ -7,6 +7,7 @@
--bb-gold: #e4a634;
--bb-peach: #f4c7a3;
--bb-border: #e0d7c7;
--bb-shell-max: 1880px;
}
* {
@@ -200,7 +201,7 @@ a {
.bb-post-row {
display: grid;
grid-template-columns: 220px 1fr;
grid-template-columns: 260px 1fr;
border-top: 1px solid var(--bb-border);
}
@@ -218,15 +219,22 @@ a {
}
.bb-post-avatar {
width: 44px;
height: 44px;
border-radius: 50%;
width: 150px;
height: 150px;
border-radius: 12px;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.06);
color: var(--bb-accent, #f29b3f);
font-size: 1.1rem;
overflow: hidden;
}
.bb-post-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.bb-post-author-name {
@@ -234,9 +242,62 @@ a {
color: var(--bb-accent, #f29b3f);
}
.bb-post-author-role {
color: var(--bb-ink-muted);
font-size: 0.9rem;
margin-top: -0.2rem;
}
.bb-post-author-badge {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.15rem 0.6rem;
border-radius: 6px;
background: linear-gradient(135deg, #f4f4f4, #c9c9c9);
color: #7b1f2a;
font-weight: 700;
font-size: 0.75rem;
letter-spacing: 0.08em;
text-transform: uppercase;
border: 1px solid rgba(0, 0, 0, 0.25);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6);
width: fit-content;
}
.bb-post-author-meta {
font-size: 0.85rem;
color: var(--bb-ink-muted);
display: flex;
flex-direction: column;
gap: 0.2rem;
margin-top: 0.3rem;
}
.bb-post-author-stat {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.bb-post-author-label {
color: var(--bb-ink-muted);
font-weight: 600;
}
.bb-post-author-value {
color: var(--bb-ink);
}
.bb-post-author-value i {
color: var(--bb-accent, #f29b3f);
font-size: 1.05rem;
}
.bb-post-author-contact .bb-post-author-value {
display: inline-flex;
align-items: center;
gap: 0.4rem;
}
.bb-post-content {
@@ -322,6 +383,11 @@ a {
flex-direction: row;
align-items: center;
}
.bb-post-avatar {
width: 80px;
height: 80px;
}
}
.bb-forum-row {
@@ -749,8 +815,9 @@ a {
font-weight: 600;
}
.bb-portal-shell {
max-width: 1400px;
.container.bb-portal-shell,
.container.bb-shell-container {
max-width: var(--bb-shell-max);
}
.bb-portal-banner {
@@ -1189,6 +1256,56 @@ a {
margin-bottom: 0.8rem;
}
.bb-avatar-preview {
width: 150px;
height: 150px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.06);
display: flex;
align-items: center;
justify-content: center;
color: var(--bb-accent, #f29b3f);
font-size: 2rem;
overflow: hidden;
}
.bb-avatar-preview img {
width: 100%;
height: 100%;
object-fit: cover;
}
.bb-profile {
display: flex;
gap: 1.2rem;
align-items: center;
}
.bb-profile-avatar {
width: 150px;
height: 150px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.06);
display: flex;
align-items: center;
justify-content: center;
color: var(--bb-accent, #f29b3f);
font-size: 2rem;
overflow: hidden;
}
.bb-profile-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.bb-profile-name {
font-size: 1.4rem;
font-weight: 700;
color: var(--bb-ink);
}
.bb-portal-list {
list-style: none;
padding: 0;

View File

@@ -942,7 +942,7 @@ export default function Acp({ isAdmin }) {
if (!isAdmin) {
return (
<Container className="py-5">
<Container fluid className="py-5">
<h2 className="mb-3">{t('acp.title')}</h2>
<p className="bb-muted">{t('acp.no_access')}</p>
</Container>

View File

@@ -139,7 +139,7 @@ export default function BoardIndex() {
))
return (
<Container className="py-4 bb-portal-shell">
<Container fluid className="py-4 bb-portal-shell">
{loading && <p className="bb-muted">{t('home.loading')}</p>}
{error && <p className="text-danger">{error}</p>}
{!loading && forumTree.length === 0 && (

View File

@@ -96,7 +96,7 @@ export default function ForumView() {
}
return (
<Container className="py-5">
<Container fluid className="py-5 bb-shell-container">
{loading && <p className="bb-muted">{t('forum.loading')}</p>}
{error && <p className="text-danger">{error}</p>}
{forum && (

View File

@@ -118,7 +118,7 @@ export default function Home() {
))
return (
<Container className="pb-4 bb-portal-shell">
<Container fluid className="pb-4 bb-portal-shell">
<div className="bb-portal-layout">
<aside className="bb-portal-column bb-portal-column--left">
<div className="bb-portal-card">

View File

@@ -7,7 +7,7 @@ import { useTranslation } from 'react-i18next'
export default function Login() {
const { login } = useAuth()
const navigate = useNavigate()
const [email, setEmail] = useState('')
const [loginValue, setLoginValue] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
@@ -18,7 +18,7 @@ export default function Login() {
setError('')
setLoading(true)
try {
await login(email, password)
await login(loginValue, password)
navigate('/')
} catch (err) {
setError(err.message)
@@ -28,7 +28,7 @@ export default function Login() {
}
return (
<Container className="py-5">
<Container fluid className="py-5">
<Card className="bb-card mx-auto" style={{ maxWidth: '480px' }}>
<Card.Body>
<Card.Title className="mb-3">{t('auth.login_title')}</Card.Title>
@@ -36,11 +36,12 @@ export default function Login() {
{error && <p className="text-danger">{error}</p>}
<Form onSubmit={handleSubmit}>
<Form.Group className="mb-3">
<Form.Label>{t('form.email')}</Form.Label>
<Form.Label>{t('auth.login_identifier')}</Form.Label>
<Form.Control
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
type="text"
value={loginValue}
onChange={(event) => setLoginValue(event.target.value)}
placeholder={t('auth.login_placeholder')}
required
/>
</Form.Group>

View File

@@ -0,0 +1,65 @@
import { useEffect, useState } from 'react'
import { Container } from 'react-bootstrap'
import { useParams } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { getUserProfile } from '../api/client'
export default function Profile() {
const { id } = useParams()
const { t } = useTranslation()
const [profile, setProfile] = useState(null)
const [error, setError] = useState('')
const [loading, setLoading] = useState(true)
useEffect(() => {
let active = true
setLoading(true)
setError('')
getUserProfile(id)
.then((data) => {
if (!active) return
setProfile(data)
})
.catch((err) => {
if (!active) return
setError(err.message)
})
.finally(() => {
if (active) setLoading(false)
})
return () => {
active = false
}
}, [id])
return (
<Container fluid className="py-5 bb-portal-shell">
<div className="bb-portal-card">
<div className="bb-portal-card-title">{t('profile.title')}</div>
{loading && <p className="bb-muted">{t('profile.loading')}</p>}
{error && <p className="text-danger">{error}</p>}
{profile && (
<div className="bb-profile">
<div className="bb-profile-avatar">
{profile.avatar_url ? (
<img src={profile.avatar_url} alt="" />
) : (
<i className="bi bi-person" aria-hidden="true" />
)}
</div>
<div className="bb-profile-meta">
<div className="bb-profile-name">{profile.name}</div>
{profile.created_at && (
<div className="bb-muted">
{t('profile.registered')} {profile.created_at.slice(0, 10)}
</div>
)}
</div>
</div>
)}
</div>
</Container>
)
}

View File

@@ -33,7 +33,7 @@ export default function Register() {
}
return (
<Container className="py-5">
<Container fluid className="py-5">
<Card className="bb-card mx-auto" style={{ maxWidth: '520px' }}>
<Card.Body>
<Card.Title className="mb-3">{t('auth.register_title')}</Card.Title>

View File

@@ -52,6 +52,7 @@ export default function ThreadView() {
body: thread.body,
created_at: thread.created_at,
user_name: thread.user_name,
user_avatar_url: thread.user_avatar_url,
isRoot: true,
}
return [rootPost, ...posts]
@@ -64,7 +65,7 @@ export default function ThreadView() {
const totalPosts = allPosts.length
return (
<Container className="py-4">
<Container fluid className="py-4 bb-shell-container">
{loading && <p className="bb-muted">{t('thread.loading')}</p>}
{error && <p className="text-danger">{error}</p>}
{thread && (
@@ -116,11 +117,42 @@ export default function ThreadView() {
<article className="bb-post-row" key={post.id}>
<aside className="bb-post-author">
<div className="bb-post-avatar">
<i className="bi bi-person" aria-hidden="true" />
{post.user_avatar_url ? (
<img src={post.user_avatar_url} alt="" />
) : (
<i className="bi bi-person" aria-hidden="true" />
)}
</div>
<div className="bb-post-author-name">{authorName}</div>
<div className="bb-post-author-role">Operator</div>
<div className="bb-post-author-badge">TEAM-RHF</div>
<div className="bb-post-author-meta">
{post.isRoot ? t('thread.label') : t('thread.reply')}
<div className="bb-post-author-stat">
<span className="bb-post-author-label">Posts:</span>
<span className="bb-post-author-value">63899</span>
</div>
<div className="bb-post-author-stat">
<span className="bb-post-author-label">Registered:</span>
<span className="bb-post-author-value">18.08.2004 18:50:03</span>
</div>
<div className="bb-post-author-stat">
<span className="bb-post-author-label">Location:</span>
<span className="bb-post-author-value">Kollmar</span>
</div>
<div className="bb-post-author-stat">
<span className="bb-post-author-label">Thanks given:</span>
<span className="bb-post-author-value">7</span>
</div>
<div className="bb-post-author-stat">
<span className="bb-post-author-label">Thanks received:</span>
<span className="bb-post-author-value">5</span>
</div>
<div className="bb-post-author-stat bb-post-author-contact">
<span className="bb-post-author-label">Contact:</span>
<span className="bb-post-author-value">
<i className="bi bi-chat-dots" aria-hidden="true" />
</span>
</div>
</div>
</aside>
<div className="bb-post-content">

View File

@@ -1,9 +1,34 @@
import { useEffect, useState } from 'react'
import { Container, Form, Row, Col } from 'react-bootstrap'
import { getCurrentUser, uploadAvatar } from '../api/client'
import { useAuth } from '../context/AuthContext'
import { useTranslation } from 'react-i18next'
export default function Ucp({ theme, setTheme, accentOverride, setAccentOverride }) {
const { t, i18n } = useTranslation()
const { token } = useAuth()
const accentMode = accentOverride ? 'custom' : 'system'
const [avatarError, setAvatarError] = useState('')
const [avatarUploading, setAvatarUploading] = useState(false)
const [avatarPreview, setAvatarPreview] = useState('')
useEffect(() => {
if (!token) return
let active = true
getCurrentUser()
.then((data) => {
if (!active) return
setAvatarPreview(data?.avatar_url || '')
})
.catch(() => {
if (active) setAvatarPreview('')
})
return () => {
active = false
}
}, [token])
const handleLanguageChange = (event) => {
const locale = event.target.value
@@ -12,7 +37,48 @@ export default function Ucp({ theme, setTheme, accentOverride, setAccentOverride
}
return (
<Container className="py-5 bb-portal-shell">
<Container fluid className="py-5 bb-portal-shell">
<div className="bb-portal-card mb-4">
<div className="bb-portal-card-title">{t('ucp.profile')}</div>
<p className="bb-muted mb-4">{t('ucp.profile_hint')}</p>
<Row className="g-3 align-items-center">
<Col md="auto">
<div className="bb-avatar-preview">
{avatarPreview ? (
<img src={avatarPreview} alt="" />
) : (
<i className="bi bi-person" aria-hidden="true" />
)}
</div>
</Col>
<Col>
{avatarError && <p className="text-danger mb-2">{avatarError}</p>}
<Form.Group>
<Form.Label>{t('ucp.avatar_label')}</Form.Label>
<Form.Control
type="file"
accept="image/png,image/jpeg,image/jpg,image/gif,image/webp"
disabled={!token || avatarUploading}
onChange={async (event) => {
const file = event.target.files?.[0]
if (!file) return
setAvatarError('')
setAvatarUploading(true)
try {
const response = await uploadAvatar(file)
setAvatarPreview(response.url)
} catch (err) {
setAvatarError(err.message)
} finally {
setAvatarUploading(false)
}
}}
/>
<Form.Text className="bb-muted">{t('ucp.avatar_hint')}</Form.Text>
</Form.Group>
</Col>
</Row>
</div>
<div className="bb-portal-card">
<div className="bb-portal-card-title">{t('portal.user_control_panel')}</div>
<p className="bb-muted mb-4">{t('ucp.intro')}</p>