ACP: general settings, branding, and favicon uploads

This commit is contained in:
2026-01-02 20:01:22 +01:00
parent 8604cdf95d
commit fe1015bff1
23 changed files with 1025 additions and 25 deletions

View File

@@ -1,8 +1,20 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Button, ButtonGroup, Col, Container, Form, Modal, Row, Tab, Tabs } from 'react-bootstrap'
import { Accordion, Button, ButtonGroup, Col, Container, Form, Modal, Row, Tab, Tabs } from 'react-bootstrap'
import DataTable, { createTheme } from 'react-data-table-component'
import { useTranslation } from 'react-i18next'
import { createForum, deleteForum, listAllForums, listUsers, reorderForums, updateForum } from '../api/client'
import { useDropzone } from 'react-dropzone'
import {
createForum,
deleteForum,
fetchSetting,
listAllForums,
listUsers,
reorderForums,
saveSetting,
uploadFavicon,
uploadLogo,
updateForum,
} from '../api/client'
export default function Acp({ isAdmin }) {
const { t } = useTranslation()
@@ -19,6 +31,25 @@ export default function Acp({ isAdmin }) {
const [usersError, setUsersError] = useState('')
const [usersPage, setUsersPage] = useState(1)
const [usersPerPage, setUsersPerPage] = useState(10)
const [generalSaving, setGeneralSaving] = useState(false)
const [generalUploading, setGeneralUploading] = useState(false)
const [generalError, setGeneralError] = useState('')
const [generalSettings, setGeneralSettings] = useState({
forumName: '',
defaultTheme: 'auto',
darkAccent: '',
lightAccent: '',
darkLogo: '',
lightLogo: '',
showHeaderName: true,
faviconIco: '',
favicon16: '',
favicon32: '',
favicon48: '',
favicon64: '',
favicon128: '',
favicon256: '',
})
const [themeMode, setThemeMode] = useState(
document.documentElement.getAttribute('data-bs-theme') || 'light'
)
@@ -69,6 +100,242 @@ export default function Acp({ isAdmin }) {
})
}, [])
useEffect(() => {
if (!isAdmin) return
let active = true
const loadSettings = async () => {
try {
const keys = [
'forum_name',
'default_theme',
'accent_color_dark',
'accent_color_light',
'logo_dark',
'logo_light',
'show_header_name',
'favicon_ico',
'favicon_16',
'favicon_32',
'favicon_48',
'favicon_64',
'favicon_128',
'favicon_256',
]
const results = await Promise.all(keys.map((key) => fetchSetting(key)))
if (!active) return
const next = {
forumName: results[0]?.value || '',
defaultTheme: results[1]?.value || 'auto',
darkAccent: results[2]?.value || '',
lightAccent: results[3]?.value || '',
darkLogo: results[4]?.value || '',
lightLogo: results[5]?.value || '',
showHeaderName: results[6]?.value !== 'false',
faviconIco: results[7]?.value || '',
favicon16: results[8]?.value || '',
favicon32: results[9]?.value || '',
favicon48: results[10]?.value || '',
favicon64: results[11]?.value || '',
favicon128: results[12]?.value || '',
favicon256: results[13]?.value || '',
}
setGeneralSettings(next)
} catch (err) {
if (active) setGeneralError(err.message)
}
}
loadSettings()
return () => {
active = false
}
}, [isAdmin])
const handleGeneralSave = async (event) => {
event.preventDefault()
setGeneralSaving(true)
setGeneralError('')
try {
await Promise.all([
saveSetting('forum_name', generalSettings.forumName.trim() || ''),
saveSetting('default_theme', generalSettings.defaultTheme || 'auto'),
saveSetting('accent_color_dark', generalSettings.darkAccent.trim() || ''),
saveSetting('accent_color_light', generalSettings.lightAccent.trim() || ''),
saveSetting('logo_dark', generalSettings.darkLogo.trim() || ''),
saveSetting('logo_light', generalSettings.lightLogo.trim() || ''),
saveSetting('show_header_name', generalSettings.showHeaderName ? 'true' : 'false'),
saveSetting('favicon_ico', generalSettings.faviconIco.trim() || ''),
saveSetting('favicon_16', generalSettings.favicon16.trim() || ''),
saveSetting('favicon_32', generalSettings.favicon32.trim() || ''),
saveSetting('favicon_48', generalSettings.favicon48.trim() || ''),
saveSetting('favicon_64', generalSettings.favicon64.trim() || ''),
saveSetting('favicon_128', generalSettings.favicon128.trim() || ''),
saveSetting('favicon_256', generalSettings.favicon256.trim() || ''),
])
window.dispatchEvent(
new CustomEvent('speedbb-settings-updated', {
detail: {
forumName: generalSettings.forumName.trim() || '',
defaultTheme: generalSettings.defaultTheme || 'auto',
accentDark: generalSettings.darkAccent.trim() || '',
accentLight: generalSettings.lightAccent.trim() || '',
logoDark: generalSettings.darkLogo.trim() || '',
logoLight: generalSettings.lightLogo.trim() || '',
showHeaderName: generalSettings.showHeaderName,
faviconIco: generalSettings.faviconIco.trim() || '',
favicon16: generalSettings.favicon16.trim() || '',
favicon32: generalSettings.favicon32.trim() || '',
favicon48: generalSettings.favicon48.trim() || '',
favicon64: generalSettings.favicon64.trim() || '',
favicon128: generalSettings.favicon128.trim() || '',
favicon256: generalSettings.favicon256.trim() || '',
},
})
)
} catch (err) {
setGeneralError(err.message)
} finally {
setGeneralSaving(false)
}
}
const handleDefaultThemeChange = async (value) => {
const previous = generalSettings.defaultTheme
setGeneralSettings((prev) => ({ ...prev, defaultTheme: value }))
setGeneralError('')
try {
await saveSetting('default_theme', value)
window.dispatchEvent(
new CustomEvent('speedbb-settings-updated', {
detail: { defaultTheme: value },
})
)
} catch (err) {
setGeneralSettings((prev) => ({ ...prev, defaultTheme: previous }))
setGeneralError(err.message)
}
}
const handleLogoUpload = async (file, variantKey) => {
if (!file) return
setGeneralUploading(true)
setGeneralError('')
try {
const result = await uploadLogo(file)
const url = result?.url || ''
const settingKey = variantKey === 'darkLogo' ? 'logo_dark' : 'logo_light'
setGeneralSettings((prev) => ({ ...prev, [variantKey]: url }))
if (url) {
await saveSetting(settingKey, url)
}
} catch (err) {
setGeneralError(err.message)
} finally {
setGeneralUploading(false)
}
}
const handleFaviconUpload = async (file, settingKey, stateKey) => {
if (!file) return
setGeneralUploading(true)
setGeneralError('')
try {
const result = await uploadFavicon(file)
const url = result?.url || ''
setGeneralSettings((prev) => ({ ...prev, [stateKey]: url }))
if (url) {
await saveSetting(settingKey, url)
window.dispatchEvent(
new CustomEvent('speedbb-settings-updated', {
detail: { [stateKey]: url },
})
)
}
} catch (err) {
setGeneralError(err.message)
} finally {
setGeneralUploading(false)
}
}
const faviconIcoDropzone = useDropzone({
accept: {
'image/png': ['.png'],
'image/x-icon': ['.ico'],
},
maxFiles: 1,
onDrop: (files) => handleFaviconUpload(files[0], 'favicon_ico', 'faviconIco'),
})
const favicon16Dropzone = useDropzone({
accept: {
'image/png': ['.png'],
'image/x-icon': ['.ico'],
},
maxFiles: 1,
onDrop: (files) => handleFaviconUpload(files[0], 'favicon_16', 'favicon16'),
})
const favicon32Dropzone = useDropzone({
accept: {
'image/png': ['.png'],
'image/x-icon': ['.ico'],
},
maxFiles: 1,
onDrop: (files) => handleFaviconUpload(files[0], 'favicon_32', 'favicon32'),
})
const favicon48Dropzone = useDropzone({
accept: {
'image/png': ['.png'],
'image/x-icon': ['.ico'],
},
maxFiles: 1,
onDrop: (files) => handleFaviconUpload(files[0], 'favicon_48', 'favicon48'),
})
const favicon64Dropzone = useDropzone({
accept: {
'image/png': ['.png'],
'image/x-icon': ['.ico'],
},
maxFiles: 1,
onDrop: (files) => handleFaviconUpload(files[0], 'favicon_64', 'favicon64'),
})
const favicon128Dropzone = useDropzone({
accept: {
'image/png': ['.png'],
'image/x-icon': ['.ico'],
},
maxFiles: 1,
onDrop: (files) => handleFaviconUpload(files[0], 'favicon_128', 'favicon128'),
})
const favicon256Dropzone = useDropzone({
accept: {
'image/png': ['.png'],
'image/x-icon': ['.ico'],
},
maxFiles: 1,
onDrop: (files) => handleFaviconUpload(files[0], 'favicon_256', 'favicon256'),
})
const darkLogoDropzone = useDropzone({
accept: {
'image/*': ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg'],
},
maxFiles: 1,
onDrop: (files) => handleLogoUpload(files[0], 'darkLogo'),
})
const lightLogoDropzone = useDropzone({
accept: {
'image/*': ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg'],
},
maxFiles: 1,
onDrop: (files) => handleLogoUpload(files[0], 'lightLogo'),
})
useEffect(() => {
const observer = new MutationObserver(() => {
setThemeMode(document.documentElement.getAttribute('data-bs-theme') || 'light')
@@ -695,6 +962,296 @@ export default function Acp({ isAdmin }) {
<Tabs defaultActiveKey="general" className="mb-3">
<Tab eventKey="general" title={t('acp.general')}>
<p className="bb-muted">{t('acp.general_hint')}</p>
{generalError && <p className="text-danger">{generalError}</p>}
<Form onSubmit={handleGeneralSave} className="bb-acp-general">
<Row className="g-3">
<Col lg={6}>
<Form.Group>
<Form.Label>{t('acp.forum_name')}</Form.Label>
<Form.Control
type="text"
value={generalSettings.forumName}
onChange={(event) =>
setGeneralSettings((prev) => ({ ...prev, forumName: event.target.value }))
}
/>
</Form.Group>
<Form.Group className="mt-2">
<Form.Check
type="checkbox"
id="acp-show-header-name"
label={t('acp.show_header_name')}
checked={generalSettings.showHeaderName}
onChange={(event) =>
setGeneralSettings((prev) => ({
...prev,
showHeaderName: event.target.checked,
}))
}
/>
</Form.Group>
</Col>
<Col lg={6}>
<Form.Group>
<Form.Label>{t('acp.default_theme')}</Form.Label>
<Form.Select
value={generalSettings.defaultTheme}
onChange={(event) => handleDefaultThemeChange(event.target.value)}
>
<option value="auto">{t('ucp.system_default')}</option>
<option value="dark">{t('nav.theme_dark')}</option>
<option value="light">{t('nav.theme_light')}</option>
</Form.Select>
</Form.Group>
</Col>
<Col lg={6}>
<Form.Group>
<Form.Label>{t('acp.accent_dark')}</Form.Label>
<div className="d-flex align-items-center gap-2">
<Form.Control
type="text"
value={generalSettings.darkAccent}
onChange={(event) =>
setGeneralSettings((prev) => ({
...prev,
darkAccent: event.target.value,
}))
}
placeholder="#f29b3f"
/>
<Form.Control
type="color"
value={generalSettings.darkAccent || '#f29b3f'}
onChange={(event) =>
setGeneralSettings((prev) => ({
...prev,
darkAccent: event.target.value,
}))
}
/>
</div>
</Form.Group>
</Col>
<Col lg={6}>
<Form.Group>
<Form.Label>{t('acp.accent_light')}</Form.Label>
<div className="d-flex align-items-center gap-2">
<Form.Control
type="text"
value={generalSettings.lightAccent}
onChange={(event) =>
setGeneralSettings((prev) => ({
...prev,
lightAccent: event.target.value,
}))
}
placeholder="#f29b3f"
/>
<Form.Control
type="color"
value={generalSettings.lightAccent || '#f29b3f'}
onChange={(event) =>
setGeneralSettings((prev) => ({
...prev,
lightAccent: event.target.value,
}))
}
/>
</div>
</Form.Group>
</Col>
<Col lg={6}>
<Form.Group>
<Form.Label>{t('acp.logo_dark')}</Form.Label>
<div
{...darkLogoDropzone.getRootProps({
className: 'bb-dropzone',
})}
>
<input {...darkLogoDropzone.getInputProps()} />
{generalSettings.darkLogo ? (
<div className="bb-dropzone-preview">
<img src={generalSettings.darkLogo} alt={t('acp.logo_dark')} />
</div>
) : (
<div className="bb-dropzone-placeholder">
<i className="bi bi-upload" aria-hidden="true" />
<span>{t('acp.logo_upload')}</span>
</div>
)}
</div>
</Form.Group>
</Col>
<Col lg={6}>
<Form.Group>
<Form.Label>{t('acp.logo_light')}</Form.Label>
<div
{...lightLogoDropzone.getRootProps({
className: 'bb-dropzone',
})}
>
<input {...lightLogoDropzone.getInputProps()} />
{generalSettings.lightLogo ? (
<div className="bb-dropzone-preview">
<img src={generalSettings.lightLogo} alt={t('acp.logo_light')} />
</div>
) : (
<div className="bb-dropzone-placeholder">
<i className="bi bi-upload" aria-hidden="true" />
<span>{t('acp.logo_upload')}</span>
</div>
)}
</div>
</Form.Group>
</Col>
<Col xs={12}>
<Accordion className="bb-acp-accordion">
<Accordion.Item eventKey="favicons">
<Accordion.Header>{t('acp.favicons')}</Accordion.Header>
<Accordion.Body>
<Row className="g-3">
<Col lg={4}>
<Form.Group>
<Form.Label>{t('acp.favicon_ico')}</Form.Label>
<div {...faviconIcoDropzone.getRootProps({ className: 'bb-dropzone' })}>
<input {...faviconIcoDropzone.getInputProps()} />
{generalSettings.faviconIco ? (
<div className="bb-dropzone-preview">
<img src={generalSettings.faviconIco} alt={t('acp.favicon_ico')} />
</div>
) : (
<div className="bb-dropzone-placeholder">
<i className="bi bi-upload" aria-hidden="true" />
<span>{t('acp.logo_upload')}</span>
</div>
)}
</div>
</Form.Group>
</Col>
<Col lg={4}>
<Form.Group>
<Form.Label>{t('acp.favicon_16')}</Form.Label>
<div {...favicon16Dropzone.getRootProps({ className: 'bb-dropzone' })}>
<input {...favicon16Dropzone.getInputProps()} />
{generalSettings.favicon16 ? (
<div className="bb-dropzone-preview">
<img src={generalSettings.favicon16} alt={t('acp.favicon_16')} />
</div>
) : (
<div className="bb-dropzone-placeholder">
<i className="bi bi-upload" aria-hidden="true" />
<span>{t('acp.logo_upload')}</span>
</div>
)}
</div>
</Form.Group>
</Col>
<Col lg={4}>
<Form.Group>
<Form.Label>{t('acp.favicon_32')}</Form.Label>
<div {...favicon32Dropzone.getRootProps({ className: 'bb-dropzone' })}>
<input {...favicon32Dropzone.getInputProps()} />
{generalSettings.favicon32 ? (
<div className="bb-dropzone-preview">
<img src={generalSettings.favicon32} alt={t('acp.favicon_32')} />
</div>
) : (
<div className="bb-dropzone-placeholder">
<i className="bi bi-upload" aria-hidden="true" />
<span>{t('acp.logo_upload')}</span>
</div>
)}
</div>
</Form.Group>
</Col>
<Col lg={4}>
<Form.Group>
<Form.Label>{t('acp.favicon_48')}</Form.Label>
<div {...favicon48Dropzone.getRootProps({ className: 'bb-dropzone' })}>
<input {...favicon48Dropzone.getInputProps()} />
{generalSettings.favicon48 ? (
<div className="bb-dropzone-preview">
<img src={generalSettings.favicon48} alt={t('acp.favicon_48')} />
</div>
) : (
<div className="bb-dropzone-placeholder">
<i className="bi bi-upload" aria-hidden="true" />
<span>{t('acp.logo_upload')}</span>
</div>
)}
</div>
</Form.Group>
</Col>
<Col lg={4}>
<Form.Group>
<Form.Label>{t('acp.favicon_64')}</Form.Label>
<div {...favicon64Dropzone.getRootProps({ className: 'bb-dropzone' })}>
<input {...favicon64Dropzone.getInputProps()} />
{generalSettings.favicon64 ? (
<div className="bb-dropzone-preview">
<img src={generalSettings.favicon64} alt={t('acp.favicon_64')} />
</div>
) : (
<div className="bb-dropzone-placeholder">
<i className="bi bi-upload" aria-hidden="true" />
<span>{t('acp.logo_upload')}</span>
</div>
)}
</div>
</Form.Group>
</Col>
<Col lg={4}>
<Form.Group>
<Form.Label>{t('acp.favicon_128')}</Form.Label>
<div {...favicon128Dropzone.getRootProps({ className: 'bb-dropzone' })}>
<input {...favicon128Dropzone.getInputProps()} />
{generalSettings.favicon128 ? (
<div className="bb-dropzone-preview">
<img src={generalSettings.favicon128} alt={t('acp.favicon_128')} />
</div>
) : (
<div className="bb-dropzone-placeholder">
<i className="bi bi-upload" aria-hidden="true" />
<span>{t('acp.logo_upload')}</span>
</div>
)}
</div>
</Form.Group>
</Col>
<Col lg={4}>
<Form.Group>
<Form.Label>{t('acp.favicon_256')}</Form.Label>
<div {...favicon256Dropzone.getRootProps({ className: 'bb-dropzone' })}>
<input {...favicon256Dropzone.getInputProps()} />
{generalSettings.favicon256 ? (
<div className="bb-dropzone-preview">
<img src={generalSettings.favicon256} alt={t('acp.favicon_256')} />
</div>
) : (
<div className="bb-dropzone-placeholder">
<i className="bi bi-upload" aria-hidden="true" />
<span>{t('acp.logo_upload')}</span>
</div>
)}
</div>
</Form.Group>
</Col>
</Row>
</Accordion.Body>
</Accordion.Item>
</Accordion>
</Col>
<Col xs={12} className="d-flex justify-content-end">
<Button
type="submit"
className="bb-accent-button"
disabled={generalSaving || generalUploading}
>
{generalSaving ? t('form.saving') : t('acp.save')}
</Button>
</Col>
</Row>
</Form>
</Tab>
<Tab eventKey="forums" title={t('acp.forums')}>
<p className="bb-muted">{t('acp.forums_hint')}</p>

View File

@@ -193,7 +193,17 @@ export default function ForumView() {
<div className="bb-topic-cell bb-topic-cell--replies">0</div>
<div className="bb-topic-cell bb-topic-cell--views"></div>
<div className="bb-topic-cell bb-topic-cell--last">
<span className="bb-muted">{t('thread.no_replies')}</span>
<div className="bb-topic-last">
<span className="bb-topic-last-by">
{t('thread.by')}{' '}
<span className="bb-topic-author">
{thread.user_name || t('thread.anonymous')}
</span>
</span>
{thread.created_at && (
<span className="bb-topic-date">{thread.created_at.slice(0, 10)}</span>
)}
</div>
</div>
</div>
))}