Add avatars, profiles, and auth flows
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
65
resources/js/pages/Profile.jsx
Normal file
65
resources/js/pages/Profile.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -60,6 +60,8 @@
|
||||
"acp.users": "Benutzer",
|
||||
"auth.login_hint": "Melde dich an, um neue Threads zu starten und zu antworten.",
|
||||
"auth.login_title": "Anmelden",
|
||||
"auth.login_identifier": "E-Mail oder Benutzername",
|
||||
"auth.login_placeholder": "name@example.com oder benutzername",
|
||||
"auth.register_hint": "Registriere dich mit E-Mail und einem eindeutigen Benutzernamen.",
|
||||
"auth.verify_notice": "Bitte bestätige deine E-Mail-Adresse, bevor du dich anmeldest.",
|
||||
"auth.register_title": "Konto erstellen",
|
||||
@@ -149,7 +151,14 @@
|
||||
"portal.user_profile": "Profil",
|
||||
"portal.user_logout": "Logout",
|
||||
"portal.advertisement": "Werbung",
|
||||
"profile.title": "Profil",
|
||||
"profile.loading": "Profil wird geladen...",
|
||||
"profile.registered": "Registriert:",
|
||||
"ucp.intro": "Verwalte deine grundlegenden Foren-Einstellungen.",
|
||||
"ucp.profile": "Profil",
|
||||
"ucp.profile_hint": "Aktualisiere den Avatar neben deinen Beitragen.",
|
||||
"ucp.avatar_label": "Profilbild",
|
||||
"ucp.avatar_hint": "Lade ein Bild hoch (max. 150x150px, Du kannst jpg, png, gif oder webp verwenden).",
|
||||
"ucp.system_default": "Systemstandard",
|
||||
"ucp.accent_override": "Akzentfarbe überschreiben",
|
||||
"ucp.accent_override_hint": "Wähle eine eigene Akzentfarbe für die Oberfläche.",
|
||||
|
||||
@@ -60,6 +60,8 @@
|
||||
"acp.users": "Users",
|
||||
"auth.login_hint": "Access your account to start new threads and reply.",
|
||||
"auth.login_title": "Log in",
|
||||
"auth.login_identifier": "Email or username",
|
||||
"auth.login_placeholder": "name@example.com or username",
|
||||
"auth.register_hint": "Register with an email and a unique username.",
|
||||
"auth.verify_notice": "Check your email to verify your account before logging in.",
|
||||
"auth.register_title": "Create account",
|
||||
@@ -149,7 +151,14 @@
|
||||
"portal.user_profile": "Profile",
|
||||
"portal.user_logout": "Logout",
|
||||
"portal.advertisement": "Advertisement",
|
||||
"profile.title": "Profile",
|
||||
"profile.loading": "Loading profile...",
|
||||
"profile.registered": "Registered:",
|
||||
"ucp.intro": "Manage your basic preferences for the forum.",
|
||||
"ucp.profile": "Profile",
|
||||
"ucp.profile_hint": "Update the avatar shown next to your posts.",
|
||||
"ucp.avatar_label": "Profile image",
|
||||
"ucp.avatar_hint": "Upload an image (max 150x150px, you can use jpg, png, gif, or webp).",
|
||||
"ucp.system_default": "System default",
|
||||
"ucp.accent_override": "Accent color override",
|
||||
"ucp.accent_override_hint": "Choose a custom accent color for your UI.",
|
||||
|
||||
Reference in New Issue
Block a user