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

@@ -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>